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>
This commit is contained in:
lofyer
2026-07-05 14:01:44 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent 192960ad3e
commit 1b227c2470
31 changed files with 2361 additions and 583 deletions
+309 -66
View File
@@ -16,19 +16,30 @@ from .auth import SESSION_COOKIE, create_session_token, logged_in, verify_creden
from .config import settings
from .db import Base, SessionLocal, engine, get_db, run_migrations
TRAFFIC_SAMPLE_INTERVAL = 60
PRUNE_EVERY_TICKS = 60
templates = Jinja2Templates(directory="app/templates")
async def _background_sampler() -> None:
tick = 0
while True:
await asyncio.sleep(TRAFFIC_SAMPLE_INTERVAL)
db = SessionLocal()
try:
interval = service.get_runtime_settings(db)["traffic_sample_interval"]
except Exception:
interval = 60
finally:
db.close()
await asyncio.sleep(interval)
db = SessionLocal()
try:
service.accumulate_usage(db)
service.disable_expired_peers(db)
service.sample_traffic(db)
tick += 1
if tick % PRUNE_EVERY_TICKS == 0:
service.prune_traffic_samples(db)
except Exception:
pass
finally:
@@ -41,11 +52,12 @@ async def lifespan(app: FastAPI):
run_migrations()
db = SessionLocal()
try:
service.apply_config(db)
try:
wireguard.interface_up()
except Exception:
pass
service.bootstrap_default_interface(db)
wireguard.status_module.online_threshold_seconds = (
service.get_runtime_settings(db)["online_threshold"]
)
service.prune_traffic_samples(db)
service.apply_all_configs(db)
finally:
db.close()
task = asyncio.create_task(_background_sampler())
@@ -66,6 +78,7 @@ def _fmt_bytes(num: float) -> str:
templates.env.filters["fmt_bytes"] = _fmt_bytes
templates.env.globals["server_address"] = wireguard.server_address
@app.get("/login", response_class=HTMLResponse)
@@ -97,30 +110,164 @@ def logout():
return response
def _interface_overview(db: Session) -> list[dict]:
overview = []
for iface in service.list_interfaces(db):
status = wireguard.get_status(iface.name)
peers = service.list_peers(db, iface)
overview.append(
{
"iface": iface,
"status": status,
"peers": peers,
"online": sum(
1
for p in peers
if (ps := status.peers.get(p.public_key)) is not None and ps.online
),
}
)
return overview
@app.get("/", response_class=HTMLResponse, dependencies=[logged_in])
def dashboard(request: Request, db: Session = Depends(get_db)):
status = wireguard.get_status()
peers = service.list_peers(db)
return templates.TemplateResponse(
request, "dashboard.html", {"overview": _interface_overview(db)}
)
@app.get("/interfaces", response_class=HTMLResponse, dependencies=[logged_in])
def interfaces_page(request: Request, db: Session = Depends(get_db), error: str = ""):
return templates.TemplateResponse(
request,
"dashboard.html",
"interfaces.html",
{
"status": status,
"peers": peers,
"overview": _interface_overview(db),
"candidates": service.import_candidates(db),
"error": error,
"settings": settings,
"server_address": wireguard.server_address(),
},
)
def _interfaces_error(exc: Exception) -> RedirectResponse:
return RedirectResponse(f"/interfaces?error={quote(str(exc))}", status_code=303)
@app.post("/interfaces", dependencies=[logged_in])
def create_interface(
db: Session = Depends(get_db),
name: str = Form(...),
subnet: str = Form(...),
listen_port: int = Form(...),
host: str = Form(...),
dns: str = Form(""),
allowed_ips: str = Form(""),
persistent_keepalive: int = Form(25),
peer_isolation: bool = Form(False),
):
try:
service.create_interface(
db, name, subnet, listen_port, host, dns,
allowed_ips, persistent_keepalive, peer_isolation,
)
except (ValueError, RuntimeError) as exc:
return _interfaces_error(exc)
return RedirectResponse("/interfaces", status_code=303)
def _get_interface_or_404(db: Session, interface_id: int):
iface = service.get_interface(db, interface_id)
if iface is None:
raise HTTPException(status_code=404, detail="Interface not found")
return iface
@app.post("/interfaces/import", dependencies=[logged_in])
def import_interface(
db: Session = Depends(get_db), name: str = Form(...), host: str = Form(...)
):
try:
service.import_interface(db, name, host)
except (ValueError, RuntimeError) as exc:
return _interfaces_error(exc)
return RedirectResponse("/interfaces", status_code=303)
@app.post("/interfaces/{interface_id}/update", dependencies=[logged_in])
def update_interface(
interface_id: int,
db: Session = Depends(get_db),
host: str = Form(...),
dns: str = Form(""),
allowed_ips: str = Form(""),
persistent_keepalive: int = Form(25),
peer_isolation: bool = Form(False),
mtu: int = Form(0),
mss_clamp: bool = Form(False),
fwmark: str = Form(""),
route_table: str = Form(""),
post_up: str = Form(""),
post_down: str = Form(""),
):
iface = _get_interface_or_404(db, interface_id)
try:
service.update_interface(
db, iface, host, dns, allowed_ips, persistent_keepalive, peer_isolation,
mtu, mss_clamp, fwmark, route_table, post_up, post_down,
)
except (ValueError, RuntimeError) as exc:
return _interfaces_error(exc)
return RedirectResponse("/interfaces", status_code=303)
@app.post("/interfaces/{interface_id}/toggle", dependencies=[logged_in])
def toggle_interface(interface_id: int, db: Session = Depends(get_db)):
try:
service.toggle_interface(db, _get_interface_or_404(db, interface_id))
except (ValueError, RuntimeError) as exc:
return _interfaces_error(exc)
return RedirectResponse("/interfaces", status_code=303)
@app.post("/interfaces/{interface_id}/delete", dependencies=[logged_in])
def delete_interface(
interface_id: int, db: Session = Depends(get_db), cascade: bool = Form(False)
):
iface = _get_interface_or_404(db, interface_id)
try:
service.delete_interface(db, iface, cascade)
except (ValueError, RuntimeError) as exc:
return _interfaces_error(exc)
return RedirectResponse("/interfaces", status_code=303)
@app.get("/peers", response_class=HTMLResponse, dependencies=[logged_in])
def peers_page(request: Request, db: Session = Depends(get_db), error: str = ""):
status = wireguard.get_status()
peers = service.list_peers(db)
def peers_page(
request: Request,
db: Session = Depends(get_db),
interface: int = 0,
error: str = "",
):
interfaces = service.list_interfaces(db)
current = None
if interface:
current = next((i for i in interfaces if i.id == interface), None)
if current is None and interfaces:
current = interfaces[0]
peers = service.list_peers(db, current) if current else []
status = wireguard.get_status(current.name) if current else wireguard.InterfaceStatus(name="")
return templates.TemplateResponse(
request,
"peers.html",
{"peers": peers, "status": status, "error": error, "settings": settings},
{
"peers": peers,
"status": status,
"error": error,
"interfaces": interfaces,
"current": current,
},
)
@@ -132,6 +279,7 @@ def _parse_quota_gib(value: str) -> int:
@app.post("/peers", dependencies=[logged_in])
def create_peer(
db: Session = Depends(get_db),
interface_id: int = Form(...),
name: str = Form(...),
expires_at: str = Form(""),
note: str = Form(""),
@@ -142,16 +290,18 @@ def create_peer(
extra_allowed_ips: str = Form(""),
client_allowed_ips: str = Form(""),
):
iface = _get_interface_or_404(db, interface_id)
expiry = datetime.fromisoformat(expires_at) if expires_at else None
quota = _parse_quota_gib(quota_gib)
try:
if count > 1:
service.create_peers_batch(
db, name.strip(), min(count, 50), expiry, note.strip(), quota
db, iface, name.strip(), min(count, 50), expiry, note.strip(), quota
)
return RedirectResponse("/peers", status_code=303)
return RedirectResponse(f"/peers?interface={iface.id}", status_code=303)
peer = service.create_peer(
db,
iface,
name.strip(),
expiry,
note.strip(),
@@ -162,7 +312,9 @@ def create_peer(
client_allowed_ips.strip(),
)
except ValueError as exc:
return RedirectResponse(f"/peers?error={quote(str(exc))}", status_code=303)
return RedirectResponse(
f"/peers?interface={iface.id}&error={quote(str(exc))}", status_code=303
)
return RedirectResponse(f"/peers/{peer.id}", status_code=303)
@@ -178,18 +330,21 @@ def peer_detail(
request: Request, peer_id: int, db: Session = Depends(get_db), error: str = ""
):
peer = _get_peer_or_404(db, peer_id)
status = wireguard.get_status()
_, server_public = wireguard.ensure_server_keys()
client_config = wireguard.render_client_config(peer, server_public)
iface = peer.interface
status = wireguard.get_status(iface.name)
client_config = (
wireguard.render_client_config(peer, iface) if peer.has_private_key else None
)
return templates.TemplateResponse(
request,
"peer_detail.html",
{
"peer": peer,
"iface": iface,
"peer_status": status.peers.get(peer.public_key),
"client_config": client_config,
"error": error,
"server_tunnel_ip": wireguard.server_address().split("/")[0],
"server_tunnel_ip": wireguard.server_address(iface.subnet).split("/")[0],
},
)
@@ -203,9 +358,11 @@ def update_peer(
dns: str = Form(""),
extra_allowed_ips: str = Form(""),
client_allowed_ips: str = Form(""),
persistent_keepalive: str = Form(""),
):
peer = _get_peer_or_404(db, peer_id)
try:
keepalive = int(persistent_keepalive) if persistent_keepalive.strip() else None
service.update_peer(
db,
peer,
@@ -214,6 +371,7 @@ def update_peer(
dns.strip(),
extra_allowed_ips.strip(),
client_allowed_ips.strip(),
keepalive,
)
except ValueError as exc:
return RedirectResponse(
@@ -230,7 +388,8 @@ def reset_usage(peer_id: int, db: Session = Depends(get_db)):
@app.post("/peers/{peer_id}/toggle", dependencies=[logged_in])
def toggle_peer(peer_id: int, db: Session = Depends(get_db)):
service.toggle_peer(db, _get_peer_or_404(db, peer_id))
peer = _get_peer_or_404(db, peer_id)
service.toggle_peer(db, peer)
return RedirectResponse(f"/peers/{peer_id}", status_code=303)
@@ -242,15 +401,21 @@ def rotate_peer(peer_id: int, db: Session = Depends(get_db)):
@app.post("/peers/{peer_id}/delete", dependencies=[logged_in])
def delete_peer(peer_id: int, db: Session = Depends(get_db)):
service.delete_peer(db, _get_peer_or_404(db, peer_id))
return RedirectResponse("/peers", status_code=303)
peer = _get_peer_or_404(db, peer_id)
interface_id = peer.interface_id
service.delete_peer(db, peer)
return RedirectResponse(f"/peers?interface={interface_id}", status_code=303)
@app.get("/peers/{peer_id}/config", dependencies=[logged_in])
def peer_config(peer_id: int, db: Session = Depends(get_db)):
peer = _get_peer_or_404(db, peer_id)
_, server_public = wireguard.ensure_server_keys()
config = wireguard.render_client_config(peer, server_public)
if not peer.has_private_key:
raise HTTPException(
status_code=409,
detail="Imported peer has no private key. Rotate keys first.",
)
config = wireguard.render_client_config(peer, peer.interface)
return Response(
config,
media_type="text/plain",
@@ -261,8 +426,12 @@ def peer_config(peer_id: int, db: Session = Depends(get_db)):
@app.get("/peers/{peer_id}/qr", dependencies=[logged_in])
def peer_qr(peer_id: int, db: Session = Depends(get_db)):
peer = _get_peer_or_404(db, peer_id)
_, server_public = wireguard.ensure_server_keys()
config = wireguard.render_client_config(peer, server_public)
if not peer.has_private_key:
raise HTTPException(
status_code=409,
detail="Imported peer has no private key. Rotate keys first.",
)
config = wireguard.render_client_config(peer, peer.interface)
image = qrcode.make(config)
buffer = io.BytesIO()
image.save(buffer, format="PNG")
@@ -270,53 +439,127 @@ def peer_qr(peer_id: int, db: Session = Depends(get_db)):
return StreamingResponse(buffer, media_type="image/png")
@app.get("/topology", response_class=HTMLResponse, dependencies=[logged_in])
def topology_page(request: Request):
return templates.TemplateResponse(request, "topology.html", {})
@app.get("/settings", response_class=HTMLResponse, dependencies=[logged_in])
def settings_page(request: Request):
_, server_public = wireguard.ensure_server_keys()
def settings_page(
request: Request, db: Session = Depends(get_db), error: str = "", message: str = ""
):
return templates.TemplateResponse(
request,
"settings.html",
{
"settings": settings,
"server_public": server_public,
"server_address": wireguard.server_address(),
"interfaces": service.list_interfaces(db),
"runtime": service.get_runtime_settings(db),
"tuning": wireguard.tuning.read_host_tuning(),
"error": error,
"message": message,
},
)
@app.post("/settings/runtime", dependencies=[logged_in])
def update_runtime(
db: Session = Depends(get_db),
traffic_sample_interval: int = Form(...),
traffic_retention_days: int = Form(...),
online_threshold: int = Form(...),
ui_refresh_seconds: int = Form(...),
):
try:
service.update_runtime_settings(
db,
{
"traffic_sample_interval": traffic_sample_interval,
"traffic_retention_days": traffic_retention_days,
"online_threshold": online_threshold,
"ui_refresh_seconds": ui_refresh_seconds,
},
)
except ValueError as exc:
return RedirectResponse(f"/settings?error={quote(str(exc))}", status_code=303)
return RedirectResponse("/settings?message=Saved", status_code=303)
@app.post("/settings/tuning", dependencies=[logged_in])
def apply_host_tuning(
udp_buffer_mib: int = Form(0),
netdev_backlog: int = Form(0),
gro_forwarding: str = Form(""),
):
errors: list[str] = []
if udp_buffer_mib:
if not 1 <= udp_buffer_mib <= 64:
errors.append("UDP buffer must be 1-64 MiB")
else:
errors += wireguard.tuning.apply_udp_buffers(udp_buffer_mib * 1024 * 1024)
if netdev_backlog:
if not 1000 <= netdev_backlog <= 100000:
errors.append("Backlog must be 1000-100000")
else:
errors += wireguard.tuning.apply_backlog(netdev_backlog)
if gro_forwarding in ("on", "off"):
errors += wireguard.tuning.apply_gro_forwarding(gro_forwarding == "on")
if errors:
return RedirectResponse(
f"/settings?error={quote('; '.join(errors))}", status_code=303
)
return RedirectResponse("/settings?message=Applied", status_code=303)
@app.get("/api/status", dependencies=[logged_in])
def api_status(db: Session = Depends(get_db)):
status = wireguard.get_status()
peers = service.list_peers(db)
return {
"interface": {
"name": status.name,
"up": status.up,
"listen_port": status.listen_port,
"total_rx": status.total_rx,
"total_tx": status.total_tx,
},
"peers": [
runtime = service.get_runtime_settings(db)
interfaces = []
for iface in service.list_interfaces(db):
status = wireguard.get_status(iface.name)
peers = service.list_peers(db, iface)
interfaces.append(
{
"id": peer.id,
"name": peer.name,
"address": peer.address,
"enabled": peer.enabled,
"note": peer.note,
"extra_allowed_ips": peer.extra_allowed_ips,
"client_allowed_ips": peer.client_allowed_ips,
"quota_bytes": peer.quota_bytes,
"cum_rx": peer.cum_rx,
"cum_tx": peer.cum_tx,
"over_quota": peer.over_quota,
"online": (ps := status.peers.get(peer.public_key)) is not None and ps.online,
"endpoint": ps.endpoint if ps else None,
"latest_handshake": ps.latest_handshake.isoformat()
if ps and ps.latest_handshake
else None,
"rx_bytes": ps.rx_bytes if ps else 0,
"tx_bytes": ps.tx_bytes if ps else 0,
"id": iface.id,
"name": iface.name,
"up": status.up,
"enabled": iface.enabled,
"imported": iface.imported,
"listen_port": status.listen_port or iface.listen_port,
"address": wireguard.server_address(iface.subnet),
"subnet": iface.subnet,
"host": iface.host,
"peer_isolation": iface.peer_isolation,
"total_rx": status.total_rx,
"total_tx": status.total_tx,
"peers": [
{
"id": peer.id,
"name": peer.name,
"address": peer.address,
"enabled": peer.enabled,
"note": peer.note,
"has_private_key": peer.has_private_key,
"extra_allowed_ips": peer.extra_allowed_ips,
"client_allowed_ips": peer.client_allowed_ips,
"quota_bytes": peer.quota_bytes,
"cum_rx": peer.cum_rx,
"cum_tx": peer.cum_tx,
"over_quota": peer.over_quota,
"online": (ps := status.peers.get(peer.public_key)) is not None
and ps.online,
"endpoint": ps.endpoint if ps else None,
"latest_handshake": ps.latest_handshake.isoformat()
if ps and ps.latest_handshake
else None,
"rx_bytes": ps.rx_bytes if ps else 0,
"tx_bytes": ps.tx_bytes if ps else 0,
}
for peer in peers
],
}
for peer in peers
],
)
return {
"interfaces": interfaces,
"meta": {"refresh_seconds": runtime["ui_refresh_seconds"]},
}