Files
wireguard-admin/app/service.py
T
lofyerandfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> 1b227c2470 Multi-interface management, topology view, performance tuning, precompiled CSS
- Interface model with per-interface subnet/port/keys; import/adopt existing wg-quick configs (key-less imported peers, optional key rotation), cascade delete
- Split wireguard.py into a package (keys via cryptography X25519, status, addressing, conf parse/render, sync, host tuning)
- ECharts horizontal topology view (interface -> peers -> site subnets)
- Advanced options: MTU, MSS clamping, FwMark/Table, custom PostUp/PostDown, per-peer keepalive override
- Runtime settings (sample interval/retention, online threshold, UI refresh) with traffic sample pruning; host tuning (UDP buffers, backlog, GRO forwarding)
- Precompiled Tailwind CSS replacing Play CDN runtime (fixes FOUC); stable table layout and diffed polling renders
- Host network mode in compose; NAT/isolation iptables moved into app sync

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-07-05 14:01:44 +08:00

571 lines
18 KiB
Python

import ipaddress
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from . import wireguard
from .config import settings
from .crypto import encrypt
from .models import AppSetting, Interface, Peer, TrafficSample
RUNTIME_DEFAULTS = {
"traffic_sample_interval": "60",
"traffic_retention_days": "30",
"online_threshold": "180",
"ui_refresh_seconds": "5",
}
def get_runtime_settings(db: Session) -> dict[str, int]:
stored = {s.key: s.value for s in db.scalars(select(AppSetting))}
result = {}
for key, default in RUNTIME_DEFAULTS.items():
try:
result[key] = int(stored.get(key, default))
except ValueError:
result[key] = int(default)
return result
def update_runtime_settings(db: Session, values: dict[str, int]) -> None:
bounds = {
"traffic_sample_interval": (10, 3600),
"traffic_retention_days": (0, 3650),
"online_threshold": (30, 3600),
"ui_refresh_seconds": (2, 300),
}
for key, (low, high) in bounds.items():
if key not in values:
continue
value = values[key]
if not low <= value <= high:
raise ValueError(f"{key} must be between {low} and {high}")
setting = db.get(AppSetting, key)
if setting is None:
db.add(AppSetting(key=key, value=str(value)))
else:
setting.value = str(value)
db.commit()
wireguard.status_module.online_threshold_seconds = get_runtime_settings(db)[
"online_threshold"
]
def prune_traffic_samples(db: Session) -> int:
retention_days = get_runtime_settings(db)["traffic_retention_days"]
if retention_days <= 0:
return 0
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
result = db.execute(
TrafficSample.__table__.delete().where(TrafficSample.sampled_at < cutoff)
)
db.commit()
return result.rowcount or 0
def list_interfaces(db: Session) -> list[Interface]:
return list(db.scalars(select(Interface).order_by(Interface.id)))
def get_interface(db: Session, interface_id: int) -> Interface | None:
return db.get(Interface, interface_id)
def get_interface_by_name(db: Session, name: str) -> Interface | None:
return db.scalar(select(Interface).where(Interface.name == name))
def _check_interface_conflicts(
db: Session, name: str, subnet: str, listen_port: int, exclude_id: int | None = None
) -> None:
for other in list_interfaces(db):
if other.id == exclude_id:
continue
if other.name == name:
raise ValueError(f"Interface {name} already exists")
if other.listen_port == listen_port:
raise ValueError(f"Port {listen_port} is already used by {other.name}")
if ipaddress.ip_network(subnet).overlaps(ipaddress.ip_network(other.subnet)):
raise ValueError(f"Subnet {subnet} overlaps {other.name} ({other.subnet})")
def create_interface(
db: Session,
name: str,
subnet: str,
listen_port: int,
host: str,
dns: str = "",
allowed_ips: str = "",
persistent_keepalive: int = 25,
peer_isolation: bool = False,
) -> Interface:
name = wireguard.validate_interface_name(name)
subnet = wireguard.validate_subnet(subnet)
if not 1 <= listen_port <= 65535:
raise ValueError(f"Invalid port: {listen_port}")
host = host.strip()
if not host:
raise ValueError("Endpoint host is required")
_check_interface_conflicts(db, name, subnet, listen_port)
if name in wireguard.system_interfaces():
raise ValueError(
f"Interface {name} is already running on this host. "
"Import it instead of creating a new one."
)
private, public = wireguard.generate_keypair()
iface = Interface(
name=name,
subnet=subnet,
listen_port=listen_port,
host=host,
dns=dns.strip() or settings.wg_dns,
allowed_ips=wireguard.validate_cidr_list(allowed_ips) or settings.wg_allowed_ips,
persistent_keepalive=persistent_keepalive,
peer_isolation=peer_isolation,
private_key_enc=encrypt(private),
public_key=public,
)
db.add(iface)
db.commit()
apply_config(db, iface)
try:
wireguard.interface_up(iface)
except Exception:
pass
return iface
def _validate_mtu(mtu: int) -> int:
if mtu and not 1280 <= mtu <= 1500:
raise ValueError("MTU must be between 1280 and 1500 (0 = default)")
return mtu
def _validate_mark(value: str, label: str) -> str:
value = value.strip()
if value and not value.replace("x", "").replace("X", "").isalnum():
raise ValueError(f"Invalid {label}: {value}")
return value
def update_interface(
db: Session,
iface: Interface,
host: str,
dns: str,
allowed_ips: str,
persistent_keepalive: int,
peer_isolation: bool,
mtu: int = 0,
mss_clamp: bool = False,
fwmark: str = "",
route_table: str = "",
post_up: str = "",
post_down: str = "",
) -> Interface:
host = host.strip()
if not host:
raise ValueError("Endpoint host is required")
iface.host = host
iface.dns = dns.strip() or settings.wg_dns
iface.allowed_ips = (
wireguard.validate_cidr_list(allowed_ips) or settings.wg_allowed_ips
)
iface.persistent_keepalive = persistent_keepalive
iface.peer_isolation = peer_isolation
iface.mtu = _validate_mtu(mtu)
iface.mss_clamp = mss_clamp
iface.fwmark = _validate_mark(fwmark, "FwMark")
iface.route_table = _validate_mark(route_table, "Table")
iface.post_up = post_up.strip()
iface.post_down = post_down.strip()
db.commit()
apply_config(db, iface)
return iface
def toggle_interface(db: Session, iface: Interface) -> Interface:
if iface.enabled:
wireguard.interface_down(iface)
iface.enabled = False
else:
iface.enabled = True
apply_config(db, iface)
wireguard.interface_up(iface)
db.commit()
return iface
def delete_interface(db: Session, iface: Interface, cascade: bool = False) -> None:
if iface.peers and not cascade:
raise ValueError(
f"Interface {iface.name} still has {len(iface.peers)} peers. "
"Delete them first or use cascade delete."
)
try:
wireguard.interface_down(iface)
except Exception:
pass
wireguard.backup_config(iface.name)
path = wireguard.config_path(iface.name)
if path.exists():
path.unlink()
db.delete(iface)
db.commit()
@dataclass
class ImportCandidate:
name: str
has_config: bool
running: bool
peer_count: int
listen_port: int | None
address: str
def import_candidates(db: Session) -> list[ImportCandidate]:
known = {iface.name for iface in list_interfaces(db)}
names = set(wireguard.discover_configs()) | set(wireguard.system_interfaces())
candidates = []
for name in sorted(names - known):
parsed = None
path = wireguard.config_path(name)
if path.exists():
try:
parsed = wireguard.parse_config(path.read_text())
except Exception:
parsed = None
status = wireguard.get_status(name)
candidates.append(
ImportCandidate(
name=name,
has_config=parsed is not None and bool(parsed.private_key),
running=status.up,
peer_count=len(parsed.peers) if parsed else len(status.peers),
listen_port=(parsed.listen_port if parsed else None) or status.listen_port,
address=parsed.address if parsed else "",
)
)
return candidates
def import_interface(db: Session, name: str, host: str) -> Interface:
name = wireguard.validate_interface_name(name)
if get_interface_by_name(db, name) is not None:
raise ValueError(f"Interface {name} is already managed")
path = wireguard.config_path(name)
if not path.exists():
raise ValueError(
f"No config file at {path}. Only wg-quick configs can be imported."
)
parsed = wireguard.parse_config(path.read_text())
if not parsed.private_key:
raise ValueError(f"{path} has no PrivateKey")
if not parsed.address:
raise ValueError(f"{path} has no Address")
if not parsed.listen_port:
raise ValueError(f"{path} has no ListenPort")
host = host.strip()
if not host:
raise ValueError("Endpoint host is required")
network = ipaddress.ip_interface(parsed.address).network
subnet = str(network)
_check_interface_conflicts(db, name, subnet, parsed.listen_port)
wireguard.backup_config(name)
iface = Interface(
name=name,
subnet=subnet,
listen_port=parsed.listen_port,
host=host,
dns=settings.wg_dns,
allowed_ips=settings.wg_allowed_ips,
persistent_keepalive=settings.wg_persistent_keepalive,
private_key_enc=encrypt(parsed.private_key),
public_key=wireguard.pubkey(parsed.private_key),
imported=True,
)
db.add(iface)
db.flush()
used_names: set[str] = set()
index = 1
for parsed_peer in parsed.peers:
address = ""
extra: list[str] = []
for cidr in parsed_peer.allowed_ips:
try:
net = ipaddress.ip_network(cidr, strict=False)
except ValueError:
continue
if not address and net.prefixlen == net.max_prefixlen and net.network_address in network:
address = f"{net.network_address}/{net.max_prefixlen}"
else:
extra.append(str(net))
if not address:
address = wireguard.next_free_address(
subnet, [p.address for p in iface.peers]
)
peer_name = parsed_peer.name.strip() or f"imported-{index}"
while peer_name in used_names:
index += 1
peer_name = f"imported-{index}"
used_names.add(peer_name)
index += 1
iface.peers.append(
Peer(
name=peer_name,
public_key=parsed_peer.public_key,
private_key_enc="",
preshared_key_enc=(
encrypt(parsed_peer.preshared_key)
if parsed_peer.preshared_key
else ""
),
address=address,
extra_allowed_ips=", ".join(extra),
)
)
db.commit()
apply_config(db, iface)
return iface
def bootstrap_default_interface(db: Session) -> None:
if list_interfaces(db):
return
private_path = settings.data_dir / "server" / "privatekey"
if private_path.exists():
private = private_path.read_text().strip()
else:
private = wireguard.genkey()
iface = Interface(
name=settings.wg_interface,
subnet=settings.wg_subnet,
listen_port=settings.wg_port,
host=settings.wg_host,
dns=settings.wg_dns,
allowed_ips=settings.wg_allowed_ips,
persistent_keepalive=settings.wg_persistent_keepalive,
peer_isolation=settings.wg_peer_isolation,
private_key_enc=encrypt(private),
public_key=wireguard.pubkey(private),
)
db.add(iface)
db.commit()
db.execute(
Peer.__table__.update()
.where(Peer.interface_id.notin_(select(Interface.id)))
.values(interface_id=iface.id)
)
db.commit()
def list_peers(db: Session, iface: Interface | None = None) -> list[Peer]:
query = select(Peer).order_by(Peer.id)
if iface is not None:
query = query.where(Peer.interface_id == iface.id)
return list(db.scalars(query))
def get_peer(db: Session, peer_id: int) -> Peer | None:
return db.get(Peer, peer_id)
def create_peer(
db: Session,
iface: Interface,
name: str,
expires_at: datetime | None = None,
note: str = "",
quota_bytes: int = 0,
address: str = "",
dns: str = "",
extra_allowed_ips: str = "",
client_allowed_ips: str = "",
) -> Peer:
if any(p.name == name for p in list_peers(db, iface)):
raise ValueError(f"Peer {name} already exists on {iface.name}")
private, public = wireguard.generate_keypair()
psk = wireguard.genpsk()
taken = [p.address for p in list_peers(db, iface)]
if address:
address = wireguard.validate_address(address, iface.subnet, taken)
else:
address = wireguard.next_free_address(iface.subnet, taken)
peer = Peer(
interface_id=iface.id,
name=name,
public_key=public,
private_key_enc=encrypt(private),
preshared_key_enc=encrypt(psk),
address=address,
expires_at=expires_at,
note=note,
quota_bytes=quota_bytes,
dns=dns,
extra_allowed_ips=wireguard.validate_cidr_list(extra_allowed_ips),
client_allowed_ips=wireguard.validate_cidr_list(client_allowed_ips),
)
db.add(peer)
db.commit()
apply_config(db, iface)
return peer
def create_peers_batch(
db: Session,
iface: Interface,
base_name: str,
count: int,
expires_at: datetime | None = None,
note: str = "",
quota_bytes: int = 0,
) -> list[Peer]:
existing = {p.name for p in list_peers(db, iface)}
peers = []
index = 1
for _ in range(count):
while f"{base_name}-{index}" in existing:
index += 1
name = f"{base_name}-{index}"
existing.add(name)
peers.append(create_peer(db, iface, name, expires_at, note, quota_bytes))
return peers
def update_peer(
db: Session,
peer: Peer,
note: str,
quota_bytes: int,
dns: str = "",
extra_allowed_ips: str = "",
client_allowed_ips: str = "",
persistent_keepalive: int | None = None,
) -> Peer:
peer.note = note
peer.quota_bytes = quota_bytes
peer.dns = dns
peer.extra_allowed_ips = wireguard.validate_cidr_list(extra_allowed_ips)
peer.client_allowed_ips = wireguard.validate_cidr_list(client_allowed_ips)
if persistent_keepalive is not None and not 0 <= persistent_keepalive <= 3600:
raise ValueError("Keepalive must be between 0 and 3600")
peer.persistent_keepalive = persistent_keepalive
db.commit()
if not peer.over_quota:
apply_config(db, peer.interface)
return peer
def reset_peer_usage(db: Session, peer: Peer) -> Peer:
peer.cum_rx = 0
peer.cum_tx = 0
db.commit()
apply_config(db, peer.interface)
return peer
def rotate_peer_keys(db: Session, peer: Peer) -> Peer:
private, public = wireguard.generate_keypair()
peer.last_rx = 0
peer.last_tx = 0
peer.public_key = public
peer.private_key_enc = encrypt(private)
peer.preshared_key_enc = encrypt(wireguard.genpsk())
db.commit()
apply_config(db, peer.interface)
return peer
def toggle_peer(db: Session, peer: Peer) -> Peer:
peer.enabled = not peer.enabled
db.commit()
apply_config(db, peer.interface)
return peer
def delete_peer(db: Session, peer: Peer) -> None:
iface = peer.interface
db.delete(peer)
db.commit()
apply_config(db, iface)
def disable_expired_peers(db: Session) -> bool:
now = datetime.now(timezone.utc)
changed_ifaces = []
for peer in list_peers(db):
if not peer.enabled:
continue
expires = peer.expires_at
if expires is not None and expires.tzinfo is None:
expires = expires.replace(tzinfo=timezone.utc)
if (expires is not None and expires <= now) or peer.over_quota:
peer.enabled = False
if peer.interface not in changed_ifaces:
changed_ifaces.append(peer.interface)
if changed_ifaces:
db.commit()
for iface in changed_ifaces:
apply_config(db, iface)
return bool(changed_ifaces)
def accumulate_usage(db: Session) -> None:
changed = False
for iface in list_interfaces(db):
status = wireguard.get_status(iface.name)
for peer in list_peers(db, iface):
peer_status = status.peers.get(peer.public_key)
if peer_status is None:
continue
rx, tx = peer_status.rx_bytes, peer_status.tx_bytes
# wg counters reset on interface restart or peer re-add
delta_rx = rx - peer.last_rx if rx >= peer.last_rx else rx
delta_tx = tx - peer.last_tx if tx >= peer.last_tx else tx
if delta_rx or delta_tx or rx != peer.last_rx or tx != peer.last_tx:
peer.cum_rx += delta_rx
peer.cum_tx += delta_tx
peer.last_rx = rx
peer.last_tx = tx
changed = True
if changed:
db.commit()
def apply_config(db: Session, iface: Interface) -> None:
if not iface.enabled:
return
wireguard.sync_peers(iface, list_peers(db, iface))
def apply_all_configs(db: Session) -> None:
for iface in list_interfaces(db):
try:
apply_config(db, iface)
if iface.enabled:
wireguard.interface_up(iface)
except Exception:
pass
def sample_traffic(db: Session) -> None:
for iface in list_interfaces(db):
status = wireguard.get_status(iface.name)
for peer_status in status.peers.values():
db.add(
TrafficSample(
interface_name=iface.name,
peer_public_key=peer_status.public_key,
rx_bytes=peer_status.rx_bytes,
tx_bytes=peer_status.tx_bytes,
)
)
db.commit()