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:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent
192960ad3e
commit
1b227c2470
+309
-66
@@ -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"]},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user