- 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>
566 lines
19 KiB
Python
566 lines
19 KiB
Python
import asyncio
|
|
import io
|
|
from contextlib import asynccontextmanager
|
|
from datetime import datetime
|
|
from urllib.parse import quote
|
|
|
|
import qrcode
|
|
from fastapi import Depends, FastAPI, Form, HTTPException, Request
|
|
from fastapi.responses import HTMLResponse, RedirectResponse, Response, StreamingResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.templating import Jinja2Templates
|
|
from sqlalchemy.orm import Session
|
|
|
|
from . import service, wireguard
|
|
from .auth import SESSION_COOKIE, create_session_token, logged_in, verify_credentials
|
|
from .config import settings
|
|
from .db import Base, SessionLocal, engine, get_db, run_migrations
|
|
|
|
PRUNE_EVERY_TICKS = 60
|
|
|
|
templates = Jinja2Templates(directory="app/templates")
|
|
|
|
|
|
async def _background_sampler() -> None:
|
|
tick = 0
|
|
while True:
|
|
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:
|
|
db.close()
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
Base.metadata.create_all(engine)
|
|
run_migrations()
|
|
db = SessionLocal()
|
|
try:
|
|
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())
|
|
yield
|
|
task.cancel()
|
|
|
|
|
|
app = FastAPI(title="WireGuard Admin", lifespan=lifespan)
|
|
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
|
|
|
|
|
def _fmt_bytes(num: float) -> str:
|
|
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
|
|
if abs(num) < 1024:
|
|
return f"{num:.1f} {unit}"
|
|
num /= 1024
|
|
return f"{num:.1f} PiB"
|
|
|
|
|
|
templates.env.filters["fmt_bytes"] = _fmt_bytes
|
|
templates.env.globals["server_address"] = wireguard.server_address
|
|
|
|
|
|
@app.get("/login", response_class=HTMLResponse)
|
|
def login_page(request: Request):
|
|
return templates.TemplateResponse(request, "login.html", {"error": None})
|
|
|
|
|
|
@app.post("/login")
|
|
def login(request: Request, username: str = Form(...), password: str = Form(...)):
|
|
if not verify_credentials(username, password):
|
|
return templates.TemplateResponse(
|
|
request, "login.html", {"error": "Invalid username or password"}, status_code=401
|
|
)
|
|
response = RedirectResponse("/", status_code=303)
|
|
response.set_cookie(
|
|
SESSION_COOKIE,
|
|
create_session_token(),
|
|
max_age=settings.session_max_age,
|
|
httponly=True,
|
|
samesite="lax",
|
|
)
|
|
return response
|
|
|
|
|
|
@app.post("/logout")
|
|
def logout():
|
|
response = RedirectResponse("/login", status_code=303)
|
|
response.delete_cookie(SESSION_COOKIE)
|
|
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)):
|
|
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,
|
|
"interfaces.html",
|
|
{
|
|
"overview": _interface_overview(db),
|
|
"candidates": service.import_candidates(db),
|
|
"error": error,
|
|
"settings": settings,
|
|
},
|
|
)
|
|
|
|
|
|
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),
|
|
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,
|
|
"interfaces": interfaces,
|
|
"current": current,
|
|
},
|
|
)
|
|
|
|
|
|
def _parse_quota_gib(value: str) -> int:
|
|
quota = float(value) if value.strip() else 0
|
|
return int(quota * 1024**3)
|
|
|
|
|
|
@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(""),
|
|
quota_gib: str = Form(""),
|
|
count: int = Form(1),
|
|
address: str = Form(""),
|
|
dns: str = Form(""),
|
|
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, iface, name.strip(), min(count, 50), expiry, note.strip(), quota
|
|
)
|
|
return RedirectResponse(f"/peers?interface={iface.id}", status_code=303)
|
|
peer = service.create_peer(
|
|
db,
|
|
iface,
|
|
name.strip(),
|
|
expiry,
|
|
note.strip(),
|
|
quota,
|
|
address.strip(),
|
|
dns.strip(),
|
|
extra_allowed_ips.strip(),
|
|
client_allowed_ips.strip(),
|
|
)
|
|
except ValueError as exc:
|
|
return RedirectResponse(
|
|
f"/peers?interface={iface.id}&error={quote(str(exc))}", status_code=303
|
|
)
|
|
return RedirectResponse(f"/peers/{peer.id}", status_code=303)
|
|
|
|
|
|
def _get_peer_or_404(db: Session, peer_id: int):
|
|
peer = service.get_peer(db, peer_id)
|
|
if peer is None:
|
|
raise HTTPException(status_code=404, detail="Peer not found")
|
|
return peer
|
|
|
|
|
|
@app.get("/peers/{peer_id}", response_class=HTMLResponse, dependencies=[logged_in])
|
|
def peer_detail(
|
|
request: Request, peer_id: int, db: Session = Depends(get_db), error: str = ""
|
|
):
|
|
peer = _get_peer_or_404(db, peer_id)
|
|
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(iface.subnet).split("/")[0],
|
|
},
|
|
)
|
|
|
|
|
|
@app.post("/peers/{peer_id}/update", dependencies=[logged_in])
|
|
def update_peer(
|
|
peer_id: int,
|
|
db: Session = Depends(get_db),
|
|
note: str = Form(""),
|
|
quota_gib: str = Form(""),
|
|
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,
|
|
note.strip(),
|
|
_parse_quota_gib(quota_gib),
|
|
dns.strip(),
|
|
extra_allowed_ips.strip(),
|
|
client_allowed_ips.strip(),
|
|
keepalive,
|
|
)
|
|
except ValueError as exc:
|
|
return RedirectResponse(
|
|
f"/peers/{peer_id}?error={quote(str(exc))}", status_code=303
|
|
)
|
|
return RedirectResponse(f"/peers/{peer_id}", status_code=303)
|
|
|
|
|
|
@app.post("/peers/{peer_id}/reset-usage", dependencies=[logged_in])
|
|
def reset_usage(peer_id: int, db: Session = Depends(get_db)):
|
|
service.reset_peer_usage(db, _get_peer_or_404(db, peer_id))
|
|
return RedirectResponse(f"/peers/{peer_id}", status_code=303)
|
|
|
|
|
|
@app.post("/peers/{peer_id}/toggle", dependencies=[logged_in])
|
|
def toggle_peer(peer_id: int, db: Session = Depends(get_db)):
|
|
peer = _get_peer_or_404(db, peer_id)
|
|
service.toggle_peer(db, peer)
|
|
return RedirectResponse(f"/peers/{peer_id}", status_code=303)
|
|
|
|
|
|
@app.post("/peers/{peer_id}/rotate", dependencies=[logged_in])
|
|
def rotate_peer(peer_id: int, db: Session = Depends(get_db)):
|
|
service.rotate_peer_keys(db, _get_peer_or_404(db, peer_id))
|
|
return RedirectResponse(f"/peers/{peer_id}", status_code=303)
|
|
|
|
|
|
@app.post("/peers/{peer_id}/delete", dependencies=[logged_in])
|
|
def delete_peer(peer_id: int, db: Session = Depends(get_db)):
|
|
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)
|
|
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",
|
|
headers={"Content-Disposition": f'attachment; filename="{peer.name}.conf"'},
|
|
)
|
|
|
|
|
|
@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)
|
|
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")
|
|
buffer.seek(0)
|
|
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, db: Session = Depends(get_db), error: str = "", message: str = ""
|
|
):
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"settings.html",
|
|
{
|
|
"settings": settings,
|
|
"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)):
|
|
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": 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
|
|
],
|
|
}
|
|
)
|
|
return {
|
|
"interfaces": interfaces,
|
|
"meta": {"refresh_seconds": runtime["ui_refresh_seconds"]},
|
|
}
|