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
@@ -35,13 +35,99 @@ PEER_MIGRATIONS = {
|
||||
"last_tx": "ALTER TABLE peers ADD COLUMN last_tx INTEGER NOT NULL DEFAULT 0",
|
||||
}
|
||||
|
||||
PEER_COLUMNS = (
|
||||
"id, interface_id, name, public_key, private_key_enc, preshared_key_enc, "
|
||||
"address, enabled, expires_at, created_at, note, dns, extra_allowed_ips, "
|
||||
"client_allowed_ips, quota_bytes, cum_rx, cum_tx, last_rx, last_tx"
|
||||
)
|
||||
|
||||
|
||||
def _columns(conn, table: str) -> set[str]:
|
||||
return {row[1] for row in conn.execute(text(f"PRAGMA table_info({table})"))}
|
||||
|
||||
|
||||
def _rebuild_peers_with_interface(conn) -> None:
|
||||
# SQLite cannot alter unique constraints in place; rebuild the table so
|
||||
# name/address become unique per interface instead of globally.
|
||||
conn.execute(text("ALTER TABLE peers RENAME TO peers_old"))
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE peers (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
interface_id INTEGER NOT NULL DEFAULT 1 REFERENCES interfaces (id) ON DELETE CASCADE,
|
||||
name VARCHAR(64) NOT NULL,
|
||||
public_key VARCHAR(64) NOT NULL UNIQUE,
|
||||
private_key_enc VARCHAR(256) NOT NULL DEFAULT '',
|
||||
preshared_key_enc VARCHAR(256) NOT NULL DEFAULT '',
|
||||
address VARCHAR(64) NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT 1,
|
||||
expires_at DATETIME,
|
||||
created_at DATETIME NOT NULL,
|
||||
note VARCHAR(256) NOT NULL DEFAULT '',
|
||||
dns VARCHAR(128) NOT NULL DEFAULT '',
|
||||
extra_allowed_ips VARCHAR(512) NOT NULL DEFAULT '',
|
||||
client_allowed_ips VARCHAR(512) NOT NULL DEFAULT '',
|
||||
quota_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
cum_rx INTEGER NOT NULL DEFAULT 0,
|
||||
cum_tx INTEGER NOT NULL DEFAULT 0,
|
||||
last_rx INTEGER NOT NULL DEFAULT 0,
|
||||
last_tx INTEGER NOT NULL DEFAULT 0,
|
||||
CONSTRAINT uq_peer_iface_name UNIQUE (interface_id, name),
|
||||
CONSTRAINT uq_peer_iface_address UNIQUE (interface_id, address)
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
old_columns = _columns(conn, "peers_old")
|
||||
select_columns = ", ".join(
|
||||
column.strip() if column.strip() in old_columns else f"1 AS {column.strip()}"
|
||||
for column in PEER_COLUMNS.split(",")
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
f"INSERT INTO peers ({PEER_COLUMNS}) SELECT {select_columns} FROM peers_old"
|
||||
)
|
||||
)
|
||||
conn.execute(text("DROP TABLE peers_old"))
|
||||
conn.execute(
|
||||
text("CREATE INDEX ix_peers_interface_id ON peers (interface_id)")
|
||||
)
|
||||
|
||||
|
||||
INTERFACE_MIGRATIONS = {
|
||||
"mtu": "ALTER TABLE interfaces ADD COLUMN mtu INTEGER NOT NULL DEFAULT 0",
|
||||
"mss_clamp": "ALTER TABLE interfaces ADD COLUMN mss_clamp BOOLEAN NOT NULL DEFAULT 0",
|
||||
"fwmark": "ALTER TABLE interfaces ADD COLUMN fwmark VARCHAR(32) NOT NULL DEFAULT ''",
|
||||
"route_table": "ALTER TABLE interfaces ADD COLUMN route_table VARCHAR(32) NOT NULL DEFAULT ''",
|
||||
"post_up": "ALTER TABLE interfaces ADD COLUMN post_up VARCHAR(2048) NOT NULL DEFAULT ''",
|
||||
"post_down": "ALTER TABLE interfaces ADD COLUMN post_down VARCHAR(2048) NOT NULL DEFAULT ''",
|
||||
}
|
||||
|
||||
|
||||
def run_migrations() -> None:
|
||||
with engine.connect() as conn:
|
||||
existing = {
|
||||
row[1] for row in conn.execute(text("PRAGMA table_info(peers)"))
|
||||
}
|
||||
peer_columns = _columns(conn, "peers")
|
||||
for column, ddl in PEER_MIGRATIONS.items():
|
||||
if existing and column not in existing:
|
||||
if peer_columns and column not in peer_columns:
|
||||
conn.execute(text(ddl))
|
||||
if peer_columns and "interface_id" not in peer_columns:
|
||||
_rebuild_peers_with_interface(conn)
|
||||
peer_columns = _columns(conn, "peers")
|
||||
if peer_columns and "persistent_keepalive" not in peer_columns:
|
||||
conn.execute(
|
||||
text("ALTER TABLE peers ADD COLUMN persistent_keepalive INTEGER")
|
||||
)
|
||||
iface_columns = _columns(conn, "interfaces")
|
||||
for column, ddl in INTERFACE_MIGRATIONS.items():
|
||||
if iface_columns and column not in iface_columns:
|
||||
conn.execute(text(ddl))
|
||||
sample_columns = _columns(conn, "traffic_samples")
|
||||
if sample_columns and "interface_name" not in sample_columns:
|
||||
conn.execute(
|
||||
text(
|
||||
"ALTER TABLE traffic_samples ADD COLUMN "
|
||||
"interface_name VARCHAR(15) NOT NULL DEFAULT ''"
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
+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"]},
|
||||
}
|
||||
|
||||
+59
-8
@@ -1,7 +1,7 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from .db import Base
|
||||
|
||||
@@ -10,15 +10,51 @@ def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class Peer(Base):
|
||||
__tablename__ = "peers"
|
||||
class Interface(Base):
|
||||
__tablename__ = "interfaces"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(64), unique=True)
|
||||
public_key: Mapped[str] = mapped_column(String(64), unique=True)
|
||||
name: Mapped[str] = mapped_column(String(15), unique=True)
|
||||
subnet: Mapped[str] = mapped_column(String(64))
|
||||
listen_port: Mapped[int] = mapped_column(Integer, unique=True)
|
||||
host: Mapped[str] = mapped_column(String(256))
|
||||
dns: Mapped[str] = mapped_column(String(128), default="1.1.1.1")
|
||||
allowed_ips: Mapped[str] = mapped_column(String(512), default="0.0.0.0/0, ::/0")
|
||||
persistent_keepalive: Mapped[int] = mapped_column(Integer, default=25)
|
||||
peer_isolation: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
mtu: Mapped[int] = mapped_column(Integer, default=0)
|
||||
mss_clamp: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
fwmark: Mapped[str] = mapped_column(String(32), default="")
|
||||
route_table: Mapped[str] = mapped_column(String(32), default="")
|
||||
post_up: Mapped[str] = mapped_column(String(2048), default="")
|
||||
post_down: Mapped[str] = mapped_column(String(2048), default="")
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
private_key_enc: Mapped[str] = mapped_column(String(256))
|
||||
preshared_key_enc: Mapped[str] = mapped_column(String(256))
|
||||
address: Mapped[str] = mapped_column(String(64), unique=True)
|
||||
public_key: Mapped[str] = mapped_column(String(64), unique=True)
|
||||
imported: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
peers: Mapped[list["Peer"]] = relationship(
|
||||
back_populates="interface", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class Peer(Base):
|
||||
__tablename__ = "peers"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("interface_id", "name", name="uq_peer_iface_name"),
|
||||
UniqueConstraint("interface_id", "address", name="uq_peer_iface_address"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
interface_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("interfaces.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(64))
|
||||
public_key: Mapped[str] = mapped_column(String(64), unique=True)
|
||||
private_key_enc: Mapped[str] = mapped_column(String(256), default="")
|
||||
preshared_key_enc: Mapped[str] = mapped_column(String(256), default="")
|
||||
address: Mapped[str] = mapped_column(String(64))
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
@@ -26,12 +62,15 @@ class Peer(Base):
|
||||
dns: Mapped[str] = mapped_column(String(128), default="")
|
||||
extra_allowed_ips: Mapped[str] = mapped_column(String(512), default="")
|
||||
client_allowed_ips: Mapped[str] = mapped_column(String(512), default="")
|
||||
persistent_keepalive: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
quota_bytes: Mapped[int] = mapped_column(Integer, default=0)
|
||||
cum_rx: Mapped[int] = mapped_column(Integer, default=0)
|
||||
cum_tx: Mapped[int] = mapped_column(Integer, default=0)
|
||||
last_rx: Mapped[int] = mapped_column(Integer, default=0)
|
||||
last_tx: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
interface: Mapped[Interface] = relationship(back_populates="peers")
|
||||
|
||||
@property
|
||||
def cum_total(self) -> int:
|
||||
return self.cum_rx + self.cum_tx
|
||||
@@ -40,11 +79,23 @@ class Peer(Base):
|
||||
def over_quota(self) -> bool:
|
||||
return self.quota_bytes > 0 and self.cum_total >= self.quota_bytes
|
||||
|
||||
@property
|
||||
def has_private_key(self) -> bool:
|
||||
return bool(self.private_key_enc)
|
||||
|
||||
|
||||
class AppSetting(Base):
|
||||
__tablename__ = "app_settings"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
value: Mapped[str] = mapped_column(String(256), default="")
|
||||
|
||||
|
||||
class TrafficSample(Base):
|
||||
__tablename__ = "traffic_samples"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
interface_name: Mapped[str] = mapped_column(String(15), default="", index=True)
|
||||
peer_public_key: Mapped[str] = mapped_column(String(64), index=True)
|
||||
rx_bytes: Mapped[int] = mapped_column(Integer, default=0)
|
||||
tx_bytes: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
+433
-46
@@ -1,15 +1,376 @@
|
||||
from datetime import datetime, timezone
|
||||
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 Peer, TrafficSample
|
||||
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 list_peers(db: Session) -> list[Peer]:
|
||||
return list(db.scalars(select(Peer).order_by(Peer.id)))
|
||||
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:
|
||||
@@ -18,6 +379,7 @@ def get_peer(db: Session, peer_id: int) -> Peer | None:
|
||||
|
||||
def create_peer(
|
||||
db: Session,
|
||||
iface: Interface,
|
||||
name: str,
|
||||
expires_at: datetime | None = None,
|
||||
note: str = "",
|
||||
@@ -27,14 +389,17 @@ def create_peer(
|
||||
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)]
|
||||
taken = [p.address for p in list_peers(db, iface)]
|
||||
if address:
|
||||
address = wireguard.validate_address(address, taken)
|
||||
address = wireguard.validate_address(address, iface.subnet, taken)
|
||||
else:
|
||||
address = wireguard.next_free_address(taken)
|
||||
address = wireguard.next_free_address(iface.subnet, taken)
|
||||
peer = Peer(
|
||||
interface_id=iface.id,
|
||||
name=name,
|
||||
public_key=public,
|
||||
private_key_enc=encrypt(private),
|
||||
@@ -49,19 +414,20 @@ def create_peer(
|
||||
)
|
||||
db.add(peer)
|
||||
db.commit()
|
||||
apply_config(db)
|
||||
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)}
|
||||
existing = {p.name for p in list_peers(db, iface)}
|
||||
peers = []
|
||||
index = 1
|
||||
for _ in range(count):
|
||||
@@ -69,7 +435,7 @@ def create_peers_batch(
|
||||
index += 1
|
||||
name = f"{base_name}-{index}"
|
||||
existing.add(name)
|
||||
peers.append(create_peer(db, name, expires_at, note, quota_bytes))
|
||||
peers.append(create_peer(db, iface, name, expires_at, note, quota_bytes))
|
||||
return peers
|
||||
|
||||
|
||||
@@ -81,15 +447,19 @@ def update_peer(
|
||||
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)
|
||||
apply_config(db, peer.interface)
|
||||
return peer
|
||||
|
||||
|
||||
@@ -97,7 +467,7 @@ def reset_peer_usage(db: Session, peer: Peer) -> Peer:
|
||||
peer.cum_rx = 0
|
||||
peer.cum_tx = 0
|
||||
db.commit()
|
||||
apply_config(db)
|
||||
apply_config(db, peer.interface)
|
||||
return peer
|
||||
|
||||
|
||||
@@ -109,26 +479,27 @@ def rotate_peer_keys(db: Session, peer: Peer) -> Peer:
|
||||
peer.private_key_enc = encrypt(private)
|
||||
peer.preshared_key_enc = encrypt(wireguard.genpsk())
|
||||
db.commit()
|
||||
apply_config(db)
|
||||
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)
|
||||
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)
|
||||
apply_config(db, iface)
|
||||
|
||||
|
||||
def disable_expired_peers(db: Session) -> bool:
|
||||
now = datetime.now(timezone.utc)
|
||||
changed = False
|
||||
changed_ifaces = []
|
||||
for peer in list_peers(db):
|
||||
if not peer.enabled:
|
||||
continue
|
||||
@@ -137,47 +508,63 @@ def disable_expired_peers(db: Session) -> bool:
|
||||
expires = expires.replace(tzinfo=timezone.utc)
|
||||
if (expires is not None and expires <= now) or peer.over_quota:
|
||||
peer.enabled = False
|
||||
changed = True
|
||||
if changed:
|
||||
if peer.interface not in changed_ifaces:
|
||||
changed_ifaces.append(peer.interface)
|
||||
if changed_ifaces:
|
||||
db.commit()
|
||||
apply_config(db)
|
||||
return changed
|
||||
for iface in changed_ifaces:
|
||||
apply_config(db, iface)
|
||||
return bool(changed_ifaces)
|
||||
|
||||
|
||||
def accumulate_usage(db: Session) -> None:
|
||||
status = wireguard.get_status()
|
||||
changed = False
|
||||
for peer in list_peers(db):
|
||||
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
|
||||
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) -> None:
|
||||
private, _ = wireguard.ensure_server_keys()
|
||||
wireguard.sync_peers(private, list_peers(db))
|
||||
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:
|
||||
status = wireguard.get_status()
|
||||
for peer_status in status.peers.values():
|
||||
db.add(
|
||||
TrafficSample(
|
||||
peer_public_key=peer_status.public_key,
|
||||
rx_bytes=peer_status.rx_bytes,
|
||||
tx_bytes=peer_status.tx_bytes,
|
||||
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()
|
||||
|
||||
Vendored
+45
File diff suppressed because one or more lines are too long
+36
-30
@@ -1,4 +1,4 @@
|
||||
const REFRESH_MS = 5000;
|
||||
let refreshMs = 5000;
|
||||
|
||||
function fmtBytes(num) {
|
||||
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
|
||||
@@ -28,39 +28,45 @@ async function refresh() {
|
||||
return;
|
||||
}
|
||||
|
||||
const online = data.peers.filter((p) => p.online).length;
|
||||
const set = (id, value) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = value;
|
||||
};
|
||||
set("peer-count", data.peers.length);
|
||||
set("online-count", online);
|
||||
set("total-rx", fmtBytes(data.interface.total_rx));
|
||||
set("total-tx", fmtBytes(data.interface.total_tx));
|
||||
if (data.meta && data.meta.refresh_seconds) {
|
||||
refreshMs = data.meta.refresh_seconds * 1000;
|
||||
}
|
||||
|
||||
const tbody = document.querySelector("#peer-table tbody");
|
||||
if (!tbody) return;
|
||||
const badgeBase = "rounded-full px-2 py-0.5 text-xs font-medium";
|
||||
tbody.innerHTML = "";
|
||||
for (const p of data.peers) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "hover:bg-slate-50";
|
||||
const badge = p.online
|
||||
? `<span class="${badgeBase} bg-emerald-100 text-emerald-700">online</span>`
|
||||
: p.enabled
|
||||
? `<span class="${badgeBase} bg-slate-100 text-slate-500">offline</span>`
|
||||
: `<span class="${badgeBase} bg-red-100 text-red-700">disabled</span>`;
|
||||
tr.innerHTML =
|
||||
`<td class="px-4 py-3"><a href="/peers/${p.id}" class="font-medium text-blue-600 hover:underline"></a></td>` +
|
||||
`<td class="px-4 py-3 font-mono text-xs">${p.address}</td>` +
|
||||
`<td class="px-4 py-3">${badge}</td>` +
|
||||
`<td class="px-4 py-3 text-slate-500">${fmtHandshake(p.latest_handshake)}</td>` +
|
||||
`<td class="px-4 py-3 text-slate-500">${fmtBytes(p.rx_bytes)}</td>` +
|
||||
`<td class="px-4 py-3 text-slate-500">${fmtBytes(p.tx_bytes)}</td>`;
|
||||
tr.querySelector("a").textContent = p.name;
|
||||
tbody.appendChild(tr);
|
||||
const esc = (s) =>
|
||||
String(s).replace(/[&<>"]/g, (ch) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """ })[ch]
|
||||
);
|
||||
let html = "";
|
||||
for (const iface of data.interfaces) {
|
||||
for (const p of iface.peers) {
|
||||
const badge = p.online
|
||||
? `<span class="${badgeBase} bg-emerald-100 text-emerald-700">online</span>`
|
||||
: p.enabled
|
||||
? `<span class="${badgeBase} bg-slate-100 text-slate-500">offline</span>`
|
||||
: `<span class="${badgeBase} bg-red-100 text-red-700">disabled</span>`;
|
||||
html +=
|
||||
`<tr class="hover:bg-slate-50">` +
|
||||
`<td class="truncate px-4 py-3"><a href="/peers/${p.id}" class="font-medium text-blue-600 hover:underline">${esc(p.name)}</a></td>` +
|
||||
`<td class="truncate px-4 py-3 text-slate-500">${esc(iface.name)}</td>` +
|
||||
`<td class="truncate px-4 py-3 font-mono text-xs">${esc(p.address)}</td>` +
|
||||
`<td class="px-4 py-3">${badge}</td>` +
|
||||
`<td class="whitespace-nowrap px-4 py-3 text-slate-500 tabular-nums">${fmtHandshake(p.latest_handshake)}</td>` +
|
||||
`<td class="whitespace-nowrap px-4 py-3 text-slate-500 tabular-nums">${fmtBytes(p.rx_bytes)}</td>` +
|
||||
`<td class="whitespace-nowrap px-4 py-3 text-slate-500 tabular-nums">${fmtBytes(p.tx_bytes)}</td>` +
|
||||
`</tr>`;
|
||||
}
|
||||
}
|
||||
if (tbody.dataset.html !== html) {
|
||||
tbody.dataset.html = html;
|
||||
tbody.innerHTML = html;
|
||||
}
|
||||
}
|
||||
|
||||
refresh();
|
||||
setInterval(refresh, REFRESH_MS);
|
||||
async function loop() {
|
||||
await refresh();
|
||||
setTimeout(loop, refreshMs);
|
||||
}
|
||||
loop();
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,209 @@
|
||||
let refreshMs = 5000;
|
||||
let lastPayload = "";
|
||||
|
||||
const container = document.getElementById("topology-chart");
|
||||
const chart = echarts.init(container);
|
||||
chart.on("click", (params) => {
|
||||
if (params.dataType === "node" && params.data.peerId) {
|
||||
window.location.href = `/peers/${params.data.peerId}`;
|
||||
}
|
||||
});
|
||||
window.addEventListener("resize", () => chart.resize());
|
||||
|
||||
const COL_X = { server: 120, peer: 480, subnet: 840 };
|
||||
const PEER_GAP = 78;
|
||||
const SUBNET_GAP = 34;
|
||||
const BAND_GAP = 70;
|
||||
|
||||
function fmtBytes(num) {
|
||||
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
|
||||
let i = 0;
|
||||
while (Math.abs(num) >= 1024 && i < units.length - 1) {
|
||||
num /= 1024;
|
||||
i++;
|
||||
}
|
||||
return num.toFixed(1) + " " + units[i];
|
||||
}
|
||||
|
||||
function splitCidrs(value) {
|
||||
return (value || "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function peerCategory(p) {
|
||||
if (!p.enabled) return "Disabled";
|
||||
return p.online ? "Online" : "Offline";
|
||||
}
|
||||
|
||||
function buildOption(data) {
|
||||
const nodes = [];
|
||||
const links = [];
|
||||
let y = 0;
|
||||
|
||||
for (const iface of data.interfaces) {
|
||||
const bandTop = y;
|
||||
let peerY = bandTop;
|
||||
|
||||
for (const p of iface.peers) {
|
||||
const subnets = splitCidrs(p.extra_allowed_ips);
|
||||
const rowHeight = Math.max(PEER_GAP, subnets.length * SUBNET_GAP + 26);
|
||||
const rowCenter = peerY + rowHeight / 2;
|
||||
const active = p.enabled && p.online;
|
||||
|
||||
const lines = [
|
||||
`Peer: ${p.name}${p.has_private_key ? "" : " (imported)"}`,
|
||||
`Address: ${p.address}`,
|
||||
`Status: ${peerCategory(p).toLowerCase()}`,
|
||||
];
|
||||
if (p.endpoint) lines.push(`Endpoint: ${p.endpoint}`);
|
||||
lines.push(`RX ${fmtBytes(p.rx_bytes)} / TX ${fmtBytes(p.tx_bytes)}`);
|
||||
if (p.client_allowed_ips) lines.push(`Client routes: ${p.client_allowed_ips}`);
|
||||
|
||||
nodes.push({
|
||||
id: `peer-${p.id}`,
|
||||
peerId: p.id,
|
||||
name: p.name,
|
||||
category: peerCategory(p),
|
||||
x: COL_X.peer,
|
||||
y: rowCenter,
|
||||
symbolSize: 26,
|
||||
tooltipLines: lines,
|
||||
});
|
||||
links.push({
|
||||
source: `iface-${iface.id}`,
|
||||
target: `peer-${p.id}`,
|
||||
lineStyle: {
|
||||
width: active ? 2.5 : 1.5,
|
||||
type: active ? "solid" : "dashed",
|
||||
color: active ? "#10b981" : "#94a3b8",
|
||||
curveness: 0.12,
|
||||
},
|
||||
});
|
||||
|
||||
subnets.forEach((subnet, i) => {
|
||||
const id = `subnet-${p.id}-${subnet}`;
|
||||
nodes.push({
|
||||
id,
|
||||
name: subnet,
|
||||
category: "Site subnet",
|
||||
x: COL_X.subnet,
|
||||
y: rowCenter - ((subnets.length - 1) * SUBNET_GAP) / 2 + i * SUBNET_GAP,
|
||||
symbol: "roundRect",
|
||||
symbolSize: [Math.max(96, subnet.length * 7.5), 22],
|
||||
label: {
|
||||
position: "inside",
|
||||
fontSize: 10,
|
||||
fontFamily: "monospace",
|
||||
color: "#92400e",
|
||||
},
|
||||
tooltipLines: [`Site subnet: ${subnet}`, `Via peer: ${p.name}`],
|
||||
});
|
||||
links.push({
|
||||
source: `peer-${p.id}`,
|
||||
target: id,
|
||||
lineStyle: {
|
||||
width: 1.5,
|
||||
type: p.enabled ? "solid" : "dashed",
|
||||
color: "#f59e0b",
|
||||
curveness: 0.12,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
peerY += rowHeight;
|
||||
}
|
||||
|
||||
const bandHeight = Math.max(peerY - bandTop, PEER_GAP);
|
||||
nodes.push({
|
||||
id: `iface-${iface.id}`,
|
||||
name: iface.name,
|
||||
category: "Server",
|
||||
x: COL_X.server,
|
||||
y: bandTop + bandHeight / 2,
|
||||
symbolSize: 52,
|
||||
label: { fontWeight: "bold" },
|
||||
tooltipLines: [
|
||||
`Interface: ${iface.name} (${iface.up ? "up" : "down"})${iface.imported ? " [imported]" : ""}`,
|
||||
`Address: ${iface.address}`,
|
||||
`Subnet: ${iface.subnet}`,
|
||||
`Endpoint: ${iface.host}:${iface.listen_port}`,
|
||||
`Peer isolation: ${iface.peer_isolation ? "on" : "off"}`,
|
||||
`RX ${fmtBytes(iface.total_rx)} / TX ${fmtBytes(iface.total_tx)}`,
|
||||
],
|
||||
itemStyle: iface.up ? {} : { color: "#94a3b8" },
|
||||
});
|
||||
|
||||
y = bandTop + bandHeight + BAND_GAP;
|
||||
}
|
||||
|
||||
const totalHeight = Math.max(y - BAND_GAP, 200);
|
||||
container.style.height = Math.max(480, totalHeight + 120) + "px";
|
||||
chart.resize();
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
formatter: (params) =>
|
||||
params.dataType === "node" && params.data.tooltipLines
|
||||
? params.data.tooltipLines.join("<br>")
|
||||
: "",
|
||||
},
|
||||
legend: {
|
||||
bottom: 0,
|
||||
data: ["Server", "Online", "Offline", "Disabled", "Site subnet"],
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: "graph",
|
||||
layout: "none",
|
||||
roam: true,
|
||||
edgeSymbol: ["none", "arrow"],
|
||||
edgeSymbolSize: 6,
|
||||
label: { show: true, position: "right", fontSize: 11, color: "#334155" },
|
||||
categories: [
|
||||
{ name: "Server", itemStyle: { color: "#1e293b" } },
|
||||
{ name: "Online", itemStyle: { color: "#10b981" } },
|
||||
{ name: "Offline", itemStyle: { color: "#cbd5e1" } },
|
||||
{ name: "Disabled", itemStyle: { color: "#f87171" } },
|
||||
{ name: "Site subnet", itemStyle: { color: "#fef3c7", borderColor: "#f59e0b", borderWidth: 1 } },
|
||||
],
|
||||
emphasis: { focus: "adjacency" },
|
||||
data: nodes,
|
||||
links,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const res = await fetch("/api/status");
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (data.meta && data.meta.refresh_seconds) {
|
||||
refreshMs = data.meta.refresh_seconds * 1000;
|
||||
}
|
||||
const payload = JSON.stringify(data.interfaces);
|
||||
if (payload === lastPayload) return;
|
||||
lastPayload = payload;
|
||||
chart.setOption(buildOption(data), true);
|
||||
const badge = document.getElementById("isolation-badge");
|
||||
if (badge) {
|
||||
const isolated = data.interfaces
|
||||
.filter((i) => i.peer_isolation)
|
||||
.map((i) => i.name);
|
||||
badge.innerHTML = isolated.length
|
||||
? `<span class="rounded-full bg-amber-100 px-3 py-1 text-xs font-medium text-amber-700">Peer isolation on: ${isolated.join(", ")}</span>`
|
||||
: "";
|
||||
}
|
||||
} catch {
|
||||
/* keep last rendering */
|
||||
}
|
||||
}
|
||||
|
||||
async function loop() {
|
||||
await refresh();
|
||||
setTimeout(loop, refreshMs);
|
||||
}
|
||||
loop();
|
||||
@@ -4,14 +4,16 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}WireGuard Admin{% endblock %}</title>
|
||||
<script src="/static/tailwind.js"></script>
|
||||
<link rel="stylesheet" href="/static/tailwind.css">
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-100 text-slate-800">
|
||||
<nav class="bg-slate-900 text-slate-300">
|
||||
<div class="mx-auto flex max-w-5xl items-center gap-6 px-6 py-3">
|
||||
<span class="text-base font-bold tracking-wide text-white">WireGuard Admin</span>
|
||||
<a href="/" class="text-sm hover:text-white">Dashboard</a>
|
||||
<a href="/interfaces" class="text-sm hover:text-white">Interfaces</a>
|
||||
<a href="/peers" class="text-sm hover:text-white">Peers</a>
|
||||
<a href="/topology" class="text-sm hover:text-white">Topology</a>
|
||||
<a href="/settings" class="text-sm hover:text-white">Settings</a>
|
||||
<form method="post" action="/logout" class="ml-auto">
|
||||
<button type="submit" class="text-sm text-slate-400 hover:text-white">Logout</button>
|
||||
|
||||
@@ -4,44 +4,50 @@
|
||||
{% block content %}
|
||||
<h1 class="mb-6 text-2xl font-bold">Dashboard</h1>
|
||||
|
||||
<div class="mb-8 grid gap-4 sm:grid-cols-3">
|
||||
<div id="iface-cards" class="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{% for item in overview %}
|
||||
<div class="rounded-xl bg-white p-5 shadow-sm">
|
||||
<h3 class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-400">Interface</h3>
|
||||
<h3 class="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||
Interface
|
||||
{% if item.iface.imported %}
|
||||
<span class="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium normal-case text-amber-700">imported</span>
|
||||
{% endif %}
|
||||
</h3>
|
||||
<p class="flex items-center gap-2 text-lg font-semibold">
|
||||
{{ status.name }}
|
||||
{% if status.up %}
|
||||
<a href="/peers?interface={{ item.iface.id }}" class="text-blue-600 hover:underline">{{ item.iface.name }}</a>
|
||||
{% if item.status.up %}
|
||||
<span class="rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700">up</span>
|
||||
{% else %}
|
||||
<span class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700">down</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
<span class="font-mono">{{ server_address }}</span> · Port {{ status.listen_port or settings.wg_port }}
|
||||
<span class="font-mono">{{ server_address(item.iface.subnet) }}</span> · Port {{ item.status.listen_port or item.iface.listen_port }}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
{{ item.online }} / {{ item.peers | length }} peers online ·
|
||||
RX {{ item.status.total_rx | fmt_bytes }} / TX {{ item.status.total_tx | fmt_bytes }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl bg-white p-5 shadow-sm">
|
||||
<h3 class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-400">Peers</h3>
|
||||
<p class="text-lg font-semibold"><span id="peer-count">{{ peers | length }}</span> total</p>
|
||||
<p class="mt-1 text-sm text-slate-500"><span id="online-count">-</span> online</p>
|
||||
</div>
|
||||
<div class="rounded-xl bg-white p-5 shadow-sm">
|
||||
<h3 class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-400">Traffic</h3>
|
||||
<p class="text-sm">RX <span id="total-rx" class="font-semibold">{{ status.total_rx | fmt_bytes }}</span></p>
|
||||
<p class="mt-1 text-sm">TX <span id="total-tx" class="font-semibold">{{ status.total_tx | fmt_bytes }}</span></p>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="rounded-xl bg-white p-6 text-center text-slate-400 shadow-sm sm:col-span-2 lg:col-span-3">
|
||||
No interfaces. <a href="/interfaces" class="text-blue-600 hover:underline">Create or import one.</a>
|
||||
</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<h2 class="mb-3 text-lg font-semibold">Peers</h2>
|
||||
<div class="overflow-hidden rounded-xl bg-white shadow-sm">
|
||||
<table id="peer-table" class="w-full text-sm">
|
||||
<table id="peer-table" class="w-full table-fixed text-sm">
|
||||
<thead>
|
||||
<tr class="bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-400">
|
||||
<th class="px-4 py-3 font-semibold">Name</th>
|
||||
<th class="px-4 py-3 font-semibold">Address</th>
|
||||
<th class="px-4 py-3 font-semibold">Status</th>
|
||||
<th class="px-4 py-3 font-semibold">Handshake</th>
|
||||
<th class="px-4 py-3 font-semibold">RX</th>
|
||||
<th class="px-4 py-3 font-semibold">TX</th>
|
||||
<th class="w-[18%] px-4 py-3 font-semibold">Name</th>
|
||||
<th class="w-[12%] px-4 py-3 font-semibold">Interface</th>
|
||||
<th class="w-[18%] px-4 py-3 font-semibold">Address</th>
|
||||
<th class="w-[12%] px-4 py-3 font-semibold">Status</th>
|
||||
<th class="w-[14%] px-4 py-3 font-semibold">Handshake</th>
|
||||
<th class="w-[13%] px-4 py-3 font-semibold">RX</th>
|
||||
<th class="w-[13%] px-4 py-3 font-semibold">TX</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100"></tbody>
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Interfaces - WireGuard Admin{% endblock %}
|
||||
{% block breadcrumbs %}<span>/</span> <span>Interfaces</span>{% endblock %}
|
||||
{% block content %}
|
||||
<h1 class="mb-6 text-2xl font-bold">Interfaces</h1>
|
||||
|
||||
{% if error %}
|
||||
<p class="mb-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">{{ error }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="mb-8 space-y-4">
|
||||
{% for item in overview %}
|
||||
{% set iface = item.iface %}
|
||||
<div class="rounded-xl bg-white p-5 shadow-sm">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<h2 class="text-lg font-semibold">{{ iface.name }}</h2>
|
||||
{% if item.status.up %}
|
||||
<span class="rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700">up</span>
|
||||
{% else %}
|
||||
<span class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700">down</span>
|
||||
{% endif %}
|
||||
{% if not iface.enabled %}
|
||||
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-500">disabled</span>
|
||||
{% endif %}
|
||||
{% if iface.imported %}
|
||||
<span class="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700">imported</span>
|
||||
{% endif %}
|
||||
{% if iface.peer_isolation %}
|
||||
<span class="rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-700">isolation</span>
|
||||
{% endif %}
|
||||
<div class="ml-auto flex gap-2">
|
||||
<a href="/peers?interface={{ iface.id }}"
|
||||
class="rounded-md border border-slate-300 px-3 py-1 text-xs font-medium text-slate-600 hover:bg-slate-100">
|
||||
Peers ({{ item.peers | length }})
|
||||
</a>
|
||||
<form method="post" action="/interfaces/{{ iface.id }}/toggle"
|
||||
{% if iface.enabled %}onsubmit="return confirm('Bring {{ iface.name }} down? All its peers disconnect.')"{% endif %}>
|
||||
<button type="submit"
|
||||
class="rounded-md border border-slate-300 px-3 py-1 text-xs font-medium text-slate-600 hover:bg-slate-100">
|
||||
{{ "Down" if iface.enabled else "Up" }}
|
||||
</button>
|
||||
</form>
|
||||
<form method="post" action="/interfaces/{{ iface.id }}/delete"
|
||||
onsubmit="return confirmDelete(this, '{{ iface.name }}', {{ item.peers | length }})">
|
||||
<input type="hidden" name="cascade" value="">
|
||||
<button type="submit"
|
||||
class="rounded-md border border-red-200 px-3 py-1 text-xs font-medium text-red-600 hover:bg-red-50">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="mt-3 grid gap-x-8 gap-y-1 text-sm sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="flex justify-between gap-2 sm:block">
|
||||
<dt class="text-slate-500">Address</dt>
|
||||
<dd class="font-mono text-xs">{{ server_address(iface.subnet) }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-2 sm:block">
|
||||
<dt class="text-slate-500">Endpoint</dt>
|
||||
<dd class="font-mono text-xs">{{ iface.host }}:{{ iface.listen_port }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-2 sm:block">
|
||||
<dt class="text-slate-500">Online</dt>
|
||||
<dd>{{ item.online }} / {{ item.peers | length }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-2 sm:block">
|
||||
<dt class="text-slate-500">Traffic</dt>
|
||||
<dd>RX {{ item.status.total_rx | fmt_bytes }} / TX {{ item.status.total_tx | fmt_bytes }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<details class="mt-3 border-t border-slate-100 pt-3">
|
||||
<summary class="cursor-pointer text-xs font-semibold uppercase tracking-wide text-slate-400">Edit</summary>
|
||||
<form method="post" action="/interfaces/{{ iface.id }}/update"
|
||||
class="mt-3 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<label class="block text-sm font-medium text-slate-600">Endpoint host
|
||||
<input name="host" value="{{ iface.host }}" required
|
||||
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
|
||||
</label>
|
||||
<label class="block text-sm font-medium text-slate-600">Client DNS
|
||||
<input name="dns" value="{{ iface.dns }}"
|
||||
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
|
||||
</label>
|
||||
<label class="block text-sm font-medium text-slate-600">Client AllowedIPs
|
||||
<input name="allowed_ips" value="{{ iface.allowed_ips }}"
|
||||
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
|
||||
</label>
|
||||
<label class="block text-sm font-medium text-slate-600">Keepalive (s)
|
||||
<input name="persistent_keepalive" type="number" min="0" max="3600" value="{{ iface.persistent_keepalive }}"
|
||||
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
|
||||
</label>
|
||||
<label class="flex items-end gap-2 pb-2 text-sm font-medium text-slate-600">
|
||||
<input name="peer_isolation" type="checkbox" value="true" {% if iface.peer_isolation %}checked{% endif %}
|
||||
class="h-4 w-4 rounded border-slate-300">
|
||||
Peer isolation
|
||||
</label>
|
||||
<label class="block text-sm font-medium text-slate-600">MTU
|
||||
<input name="mtu" type="number" min="0" max="1500" value="{{ iface.mtu or '' }}" placeholder="1420 (default)"
|
||||
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
|
||||
<span class="mt-1 block text-xs text-slate-400">Lower for PPPoE (1412) or nested tunnels (1340). 0 = wg default.</span>
|
||||
</label>
|
||||
<label class="flex items-end gap-2 pb-2 text-sm font-medium text-slate-600">
|
||||
<input name="mss_clamp" type="checkbox" value="true" {% if iface.mss_clamp %}checked{% endif %}
|
||||
class="h-4 w-4 rounded border-slate-300">
|
||||
<span>MSS clamping
|
||||
<span class="block text-xs font-normal text-slate-400">Fixes "ping works but pages hang"</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="block text-sm font-medium text-slate-600">FwMark
|
||||
<input name="fwmark" value="{{ iface.fwmark }}" placeholder="e.g. 0x8888"
|
||||
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
|
||||
<span class="mt-1 block text-xs text-slate-400">Marks tunnel packets for policy routing.</span>
|
||||
</label>
|
||||
<label class="block text-sm font-medium text-slate-600">Route table
|
||||
<input name="route_table" value="{{ iface.route_table }}" placeholder="auto / off / table id"
|
||||
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
|
||||
<span class="mt-1 block text-xs text-slate-400">"off" disables wg-quick routes for manual control.</span>
|
||||
</label>
|
||||
<label class="block text-sm font-medium text-slate-600 sm:col-span-2 lg:col-span-1">PostUp commands
|
||||
<textarea name="post_up" rows="2" placeholder="one command per line"
|
||||
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 font-mono text-xs focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">{{ iface.post_up }}</textarea>
|
||||
</label>
|
||||
<label class="block text-sm font-medium text-slate-600 sm:col-span-2 lg:col-span-1">PostDown commands
|
||||
<textarea name="post_down" rows="2" placeholder="one command per line"
|
||||
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 font-mono text-xs focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">{{ iface.post_down }}</textarea>
|
||||
<span class="mt-1 block text-xs text-slate-400">Run as root by wg-quick on interface up/down.</span>
|
||||
</label>
|
||||
<div class="col-span-full">
|
||||
<button type="submit"
|
||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<p class="mt-2 text-xs text-slate-400">
|
||||
Subnet ({{ iface.subnet }}) and port are fixed after creation. Public key:
|
||||
<span class="break-all font-mono">{{ iface.public_key }}</span>
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="rounded-xl bg-white p-6 text-center text-slate-400 shadow-sm">No interfaces yet. Create one below.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-8 rounded-xl bg-white p-5 shadow-sm">
|
||||
<h2 class="mb-4 text-xs font-semibold uppercase tracking-wide text-slate-400">Add interface</h2>
|
||||
<form method="post" action="/interfaces" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<label class="block text-sm font-medium text-slate-600">Name
|
||||
<input name="name" required placeholder="wg2" pattern="[a-zA-Z0-9_=+.-]{1,15}"
|
||||
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
|
||||
</label>
|
||||
<label class="block text-sm font-medium text-slate-600">Subnet
|
||||
<input name="subnet" required placeholder="10.9.0.0/24"
|
||||
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
|
||||
</label>
|
||||
<label class="block text-sm font-medium text-slate-600">Listen port
|
||||
<input name="listen_port" type="number" min="1" max="65535" required placeholder="51822"
|
||||
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
|
||||
</label>
|
||||
<label class="block text-sm font-medium text-slate-600">Endpoint host
|
||||
<input name="host" required placeholder="vpn.example.com"
|
||||
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
|
||||
</label>
|
||||
<label class="block text-sm font-medium text-slate-600">Client DNS
|
||||
<input name="dns" placeholder="{{ settings.wg_dns }}"
|
||||
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
|
||||
</label>
|
||||
<label class="block text-sm font-medium text-slate-600">Client AllowedIPs
|
||||
<input name="allowed_ips" placeholder="{{ settings.wg_allowed_ips }}"
|
||||
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
|
||||
</label>
|
||||
<label class="block text-sm font-medium text-slate-600">Keepalive (s)
|
||||
<input name="persistent_keepalive" type="number" min="0" max="3600" value="25"
|
||||
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
|
||||
</label>
|
||||
<label class="flex items-end gap-2 pb-2 text-sm font-medium text-slate-600">
|
||||
<input name="peer_isolation" type="checkbox" value="true" class="h-4 w-4 rounded border-slate-300">
|
||||
Peer isolation
|
||||
</label>
|
||||
<div class="col-span-full">
|
||||
<button type="submit"
|
||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700">
|
||||
Create interface
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<p class="mt-3 text-xs text-slate-400">
|
||||
With host networking the new UDP port is reachable immediately. The interface is brought up on creation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-white p-5 shadow-sm">
|
||||
<h2 class="mb-4 text-xs font-semibold uppercase tracking-wide text-slate-400">Import existing interface</h2>
|
||||
{% if candidates %}
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-400">
|
||||
<th class="px-4 py-3 font-semibold">Name</th>
|
||||
<th class="px-4 py-3 font-semibold">State</th>
|
||||
<th class="px-4 py-3 font-semibold">Address</th>
|
||||
<th class="px-4 py-3 font-semibold">Port</th>
|
||||
<th class="px-4 py-3 font-semibold">Peers</th>
|
||||
<th class="px-4 py-3 font-semibold">Endpoint host</th>
|
||||
<th class="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for c in candidates %}
|
||||
<tr>
|
||||
<td class="px-4 py-3 font-medium">{{ c.name }}</td>
|
||||
<td class="px-4 py-3">
|
||||
{% if c.running %}
|
||||
<span class="rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700">running</span>
|
||||
{% else %}
|
||||
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-500">stopped</span>
|
||||
{% endif %}
|
||||
{% if not c.has_config %}
|
||||
<span class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700">no conf</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 font-mono text-xs">{{ c.address or "-" }}</td>
|
||||
<td class="px-4 py-3">{{ c.listen_port or "-" }}</td>
|
||||
<td class="px-4 py-3">{{ c.peer_count }}</td>
|
||||
<td class="px-4 py-3" colspan="2">
|
||||
<form method="post" action="/interfaces/import" class="flex gap-2">
|
||||
<input type="hidden" name="name" value="{{ c.name }}">
|
||||
<input name="host" required placeholder="vpn.example.com"
|
||||
class="w-full rounded-md border border-slate-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
|
||||
<button type="submit" {% if not c.has_config %}disabled{% endif %}
|
||||
class="rounded-md bg-blue-600 px-4 py-1.5 text-sm font-semibold text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-slate-300">
|
||||
Import
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="mt-3 text-xs text-slate-400">
|
||||
Importing takes over the wg-quick config: the server key, peers, preshared keys and site subnets are read
|
||||
from the .conf (a timestamped backup is kept). Imported peers have no private key, so config download and QR
|
||||
are unavailable until you rotate their keys. Endpoint host is the public address clients connect to.
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="text-sm text-slate-400">No unmanaged interfaces or configs found in the config directory.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function confirmDelete(form, name, peerCount) {
|
||||
if (peerCount > 0) {
|
||||
const cascade = confirm("Interface " + name + " has " + peerCount +
|
||||
" peers.\n\nOK = delete interface AND all its peers\nCancel = abort");
|
||||
if (!cascade) return false;
|
||||
form.cascade.value = "true";
|
||||
return true;
|
||||
}
|
||||
return confirm("Delete interface " + name + "?");
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Login - WireGuard Admin</title>
|
||||
<script src="/static/tailwind.js"></script>
|
||||
<link rel="stylesheet" href="/static/tailwind.css">
|
||||
</head>
|
||||
<body class="flex min-h-screen items-center justify-center bg-slate-900">
|
||||
<div class="w-80 rounded-xl bg-white p-8 shadow-2xl">
|
||||
|
||||
@@ -6,7 +6,13 @@
|
||||
<p class="mb-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">{{ error }}</p>
|
||||
{% endif %}
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold">{{ peer.name }}</h1>
|
||||
<h1 class="flex items-center gap-2 text-2xl font-bold">
|
||||
{{ peer.name }}
|
||||
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-500">{{ iface.name }}</span>
|
||||
{% if not peer.has_private_key %}
|
||||
<span class="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700">imported</span>
|
||||
{% endif %}
|
||||
</h1>
|
||||
<div class="flex gap-2">
|
||||
<form method="post" action="/peers/{{ peer.id }}/toggle">
|
||||
<button type="submit"
|
||||
@@ -96,6 +102,13 @@
|
||||
value="{{ '%.1f' % (peer.quota_bytes / 1073741824) if peer.quota_bytes else '' }}"
|
||||
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
|
||||
</label>
|
||||
<label class="block text-sm font-medium text-slate-600">Keepalive (s)
|
||||
<input name="persistent_keepalive" type="number" min="0" max="3600"
|
||||
value="{{ peer.persistent_keepalive if peer.persistent_keepalive is not none else '' }}"
|
||||
placeholder="interface default ({{ iface.persistent_keepalive }})"
|
||||
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
|
||||
<span class="mt-1 block text-xs text-slate-400">0 disables keepalive (fixed-IP site-to-site peers). Empty = interface default.</span>
|
||||
</label>
|
||||
<label class="block text-sm font-medium text-slate-600">Site subnets behind this peer
|
||||
<input name="extra_allowed_ips" value="{{ peer.extra_allowed_ips }}" placeholder="e.g. 192.168.5.0/24"
|
||||
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
|
||||
@@ -147,6 +160,7 @@
|
||||
|
||||
<div class="rounded-xl bg-white p-5 shadow-sm">
|
||||
<h3 class="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-400">Client config</h3>
|
||||
{% if peer.has_private_key %}
|
||||
<img src="/peers/{{ peer.id }}/qr" alt="QR code" class="mx-auto w-48 rounded-lg border border-slate-200">
|
||||
<div class="mt-4 flex justify-center gap-2">
|
||||
<a href="/peers/{{ peer.id }}/config"
|
||||
@@ -158,14 +172,34 @@
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm text-slate-500">
|
||||
This peer was imported from an existing config, so the server only knows its public key.
|
||||
The client keeps using its current config and everything works, but the panel cannot
|
||||
generate a .conf or QR code.
|
||||
</p>
|
||||
<p class="mt-3 text-sm text-slate-500">
|
||||
To manage the client config from here, rotate its keys and install the new config on the device.
|
||||
</p>
|
||||
<form method="post" action="/peers/{{ peer.id }}/rotate" class="mt-4 text-center"
|
||||
onsubmit="return confirm('Rotate keys? The device stays offline until the new config is installed.')">
|
||||
<button type="submit"
|
||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700">
|
||||
Rotate keys and generate config
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if client_config %}
|
||||
<div class="mt-4 rounded-xl bg-white p-5 shadow-sm">
|
||||
<h3 class="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-400">{{ peer.name }}.conf</h3>
|
||||
<pre id="client-config" class="overflow-x-auto rounded-lg bg-slate-900 p-4 text-xs leading-relaxed text-slate-200">{{ client_config }}</pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if client_config %}
|
||||
<div class="mt-4 rounded-xl bg-white p-5 shadow-sm">
|
||||
<h3 class="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-400">Client setup guide</h3>
|
||||
<div class="space-y-5 text-sm">
|
||||
@@ -205,6 +239,7 @@ nmcli connection up {{ peer.name }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
function copyConfig(btn) {
|
||||
|
||||
@@ -8,9 +8,26 @@
|
||||
<p class="mb-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">{{ error }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="mb-6 flex flex-wrap gap-1 border-b border-slate-200">
|
||||
{% for iface in interfaces %}
|
||||
<a href="/peers?interface={{ iface.id }}"
|
||||
class="rounded-t-md px-4 py-2 text-sm font-medium {% if current and iface.id == current.id %}border border-b-0 border-slate-200 bg-white text-blue-600{% else %}text-slate-500 hover:text-slate-700{% endif %}">
|
||||
{{ iface.name }}
|
||||
<span class="ml-1 rounded-full bg-slate-100 px-1.5 text-xs text-slate-500">{{ iface.peers | length }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
<a href="/interfaces" class="ml-auto px-4 py-2 text-sm text-slate-400 hover:text-slate-600">Manage interfaces</a>
|
||||
</div>
|
||||
|
||||
{% if not current %}
|
||||
<p class="rounded-xl bg-white p-6 text-center text-slate-400 shadow-sm">
|
||||
No interfaces configured. <a href="/interfaces" class="text-blue-600 hover:underline">Create one first.</a>
|
||||
</p>
|
||||
{% else %}
|
||||
<div class="mb-8 rounded-xl bg-white p-5 shadow-sm">
|
||||
<h2 class="mb-4 text-xs font-semibold uppercase tracking-wide text-slate-400">Add peer</h2>
|
||||
<h2 class="mb-4 text-xs font-semibold uppercase tracking-wide text-slate-400">Add peer to {{ current.name }}</h2>
|
||||
<form method="post" action="/peers" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<input type="hidden" name="interface_id" value="{{ current.id }}">
|
||||
<label class="block text-sm font-medium text-slate-600">Name
|
||||
<input name="name" required placeholder="laptop"
|
||||
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
|
||||
@@ -24,7 +41,7 @@
|
||||
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
|
||||
</label>
|
||||
<label class="block text-sm font-medium text-slate-600">DNS
|
||||
<input name="dns" placeholder="{{ settings.wg_dns }}"
|
||||
<input name="dns" placeholder="{{ current.dns }}"
|
||||
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
|
||||
</label>
|
||||
<label class="block text-sm font-medium text-slate-600">Quota (GiB)
|
||||
@@ -47,9 +64,9 @@
|
||||
<input name="client_allowed_ips" placeholder="server default"
|
||||
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
|
||||
</label>
|
||||
<div class="flex items-end">
|
||||
<div class="col-span-full">
|
||||
<button type="submit"
|
||||
class="w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700">
|
||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700">
|
||||
Add peer(s)
|
||||
</button>
|
||||
</div>
|
||||
@@ -78,6 +95,9 @@
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-4 py-3">
|
||||
<a href="/peers/{{ peer.id }}" class="font-medium text-blue-600 hover:underline">{{ peer.name }}</a>
|
||||
{% if not peer.has_private_key %}
|
||||
<span class="ml-1 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700">imported</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 font-mono text-xs">{{ peer.address }}</td>
|
||||
<td class="px-4 py-3">
|
||||
@@ -124,4 +144,5 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
+122
-26
@@ -3,32 +3,125 @@
|
||||
{% block breadcrumbs %}<span>/</span> <span>Settings</span>{% endblock %}
|
||||
{% block content %}
|
||||
<h1 class="mb-6 text-2xl font-bold">Settings</h1>
|
||||
<div class="rounded-xl bg-white p-5 shadow-sm">
|
||||
|
||||
{% if error %}
|
||||
<p class="mb-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">{{ error }}</p>
|
||||
{% endif %}
|
||||
{% if message %}
|
||||
<p class="mb-4 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{{ message }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="mb-6 rounded-xl bg-white p-5 shadow-sm">
|
||||
<h2 class="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-400">Monitoring</h2>
|
||||
<form method="post" action="/settings/runtime" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<label class="block text-sm font-medium text-slate-600">Sample interval (s)
|
||||
<input name="traffic_sample_interval" type="number" min="10" max="3600" value="{{ runtime.traffic_sample_interval }}"
|
||||
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
|
||||
<span class="mt-1 block text-xs text-slate-400">Traffic/quota/expiry check frequency.</span>
|
||||
</label>
|
||||
<label class="block text-sm font-medium text-slate-600">Sample retention (days)
|
||||
<input name="traffic_retention_days" type="number" min="0" max="3650" value="{{ runtime.traffic_retention_days }}"
|
||||
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
|
||||
<span class="mt-1 block text-xs text-slate-400">Old samples are pruned. 0 = keep forever.</span>
|
||||
</label>
|
||||
<label class="block text-sm font-medium text-slate-600">Online threshold (s)
|
||||
<input name="online_threshold" type="number" min="30" max="3600" value="{{ runtime.online_threshold }}"
|
||||
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
|
||||
<span class="mt-1 block text-xs text-slate-400">Max handshake age to count a peer online.</span>
|
||||
</label>
|
||||
<label class="block text-sm font-medium text-slate-600">UI refresh (s)
|
||||
<input name="ui_refresh_seconds" type="number" min="2" max="300" value="{{ runtime.ui_refresh_seconds }}"
|
||||
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
|
||||
<span class="mt-1 block text-xs text-slate-400">Dashboard and topology poll interval.</span>
|
||||
</label>
|
||||
<div class="col-span-full">
|
||||
<button type="submit"
|
||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="mb-6 rounded-xl bg-white p-5 shadow-sm">
|
||||
<h2 class="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-400">Host performance tuning</h2>
|
||||
<dl class="mb-4 grid gap-x-8 gap-y-1 text-sm sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="flex justify-between gap-2 sm:block">
|
||||
<dt class="text-slate-500">UDP rmem_max</dt>
|
||||
<dd class="font-mono text-xs">{{ tuning.rmem_max or "?" }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-2 sm:block">
|
||||
<dt class="text-slate-500">UDP wmem_max</dt>
|
||||
<dd class="font-mono text-xs">{{ tuning.wmem_max or "?" }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-2 sm:block">
|
||||
<dt class="text-slate-500">netdev_max_backlog</dt>
|
||||
<dd class="font-mono text-xs">{{ tuning.netdev_max_backlog or "?" }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-2 sm:block">
|
||||
<dt class="text-slate-500">GRO forwarding ({{ tuning.out_interface or "?" }})</dt>
|
||||
<dd class="font-mono text-xs">{{ tuning.gro_forwarding or "unknown" }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<form method="post" action="/settings/tuning" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<label class="block text-sm font-medium text-slate-600">UDP buffers (MiB)
|
||||
<input name="udp_buffer_mib" type="number" min="0" max="64" placeholder="16 recommended"
|
||||
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
|
||||
<span class="mt-1 block text-xs text-slate-400">rmem_max + wmem_max. Helps >500 Mbps tunnels.</span>
|
||||
</label>
|
||||
<label class="block text-sm font-medium text-slate-600">netdev_max_backlog
|
||||
<input name="netdev_backlog" type="number" min="0" max="100000" placeholder="e.g. 16384"
|
||||
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
|
||||
<span class="mt-1 block text-xs text-slate-400">Receive queue for high packet rates.</span>
|
||||
</label>
|
||||
<label class="block text-sm font-medium text-slate-600">UDP GRO forwarding
|
||||
<select name="gro_forwarding"
|
||||
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
|
||||
<option value="">leave unchanged</option>
|
||||
<option value="on">enable</option>
|
||||
<option value="off">disable</option>
|
||||
</select>
|
||||
<span class="mt-1 block text-xs text-slate-400">Boosts relay/site-to-site throughput.</span>
|
||||
</label>
|
||||
<div class="col-span-full">
|
||||
<button type="submit"
|
||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700">
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<p class="mt-3 text-xs text-slate-400">
|
||||
Applied immediately to the running kernel (requires host network mode and NET_ADMIN).
|
||||
Empty fields are left unchanged. Settings do not survive host reboots; persist them in
|
||||
/etc/sysctl.d on the host if needed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6 rounded-xl bg-white p-5 shadow-sm">
|
||||
<h2 class="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-400">Interfaces</h2>
|
||||
<dl class="divide-y divide-slate-100 text-sm">
|
||||
{% for iface in interfaces %}
|
||||
<div class="flex justify-between gap-4 py-3">
|
||||
<dt class="text-slate-500">Interface</dt>
|
||||
<dd class="font-medium">{{ settings.wg_interface }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4 py-3">
|
||||
<dt class="text-slate-500">Server address</dt>
|
||||
<dd class="font-mono text-xs">{{ server_address }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4 py-3">
|
||||
<dt class="shrink-0 text-slate-500">Server public key</dt>
|
||||
<dd class="break-all text-right font-mono text-xs">{{ server_public }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4 py-3">
|
||||
<dt class="text-slate-500">Endpoint host</dt>
|
||||
<dd class="font-medium">{{ settings.wg_host }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4 py-3">
|
||||
<dt class="text-slate-500">Listen port</dt>
|
||||
<dd class="font-medium">{{ settings.wg_port }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4 py-3">
|
||||
<dt class="text-slate-500">Subnet</dt>
|
||||
<dd class="font-mono text-xs">{{ settings.wg_subnet }}</dd>
|
||||
<dt class="text-slate-500">{{ iface.name }}</dt>
|
||||
<dd class="text-right">
|
||||
<span class="font-mono text-xs">{{ server_address(iface.subnet) }}</span>
|
||||
· {{ iface.host }}:{{ iface.listen_port }}
|
||||
<span class="ml-2 block break-all font-mono text-xs text-slate-400 sm:inline">{{ iface.public_key }}</span>
|
||||
</dd>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="py-3 text-slate-400">No interfaces configured.</p>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
<p class="mt-2 text-xs text-slate-400">
|
||||
Interfaces are managed in the database. Edit them on the
|
||||
<a href="/interfaces" class="text-blue-600 hover:underline">Interfaces</a> page.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-white p-5 shadow-sm">
|
||||
<h2 class="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-400">Defaults for new interfaces</h2>
|
||||
<dl class="divide-y divide-slate-100 text-sm">
|
||||
<div class="flex justify-between gap-4 py-3">
|
||||
<dt class="text-slate-500">Client DNS</dt>
|
||||
<dd class="font-medium">{{ settings.wg_dns }}</dd>
|
||||
@@ -42,10 +135,13 @@
|
||||
<dd class="font-medium">{{ settings.wg_persistent_keepalive }}s</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4 py-3">
|
||||
<dt class="text-slate-500">Peer isolation</dt>
|
||||
<dd class="font-medium">{{ "on" if settings.wg_peer_isolation else "off" }}</dd>
|
||||
<dt class="text-slate-500">Config directory</dt>
|
||||
<dd class="font-mono text-xs">{{ settings.wg_config_dir }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p class="mt-4 text-xs text-slate-400">Settings are read from environment variables / .env and require a restart to change.</p>
|
||||
<p class="mt-4 text-xs text-slate-400">
|
||||
These defaults come from environment variables / .env and are used when creating the first
|
||||
interface on startup and as fallbacks for new interfaces.
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Topology - WireGuard Admin{% endblock %}
|
||||
{% block breadcrumbs %}<span>/</span> <span>Topology</span>{% endblock %}
|
||||
{% block content %}
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold">Network Topology</h1>
|
||||
<div id="isolation-badge"></div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-white p-4 shadow-sm">
|
||||
<div id="topology-chart" class="w-full" style="height: 560px;"></div>
|
||||
</div>
|
||||
|
||||
<p class="mt-3 text-sm text-slate-500">
|
||||
Left to right: interface, peers, site subnets. Dashed links mean the peer is offline or disabled.
|
||||
Scroll to zoom, drag to pan, click a peer to open its detail page.
|
||||
</p>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/echarts.min.js"></script>
|
||||
<script src="/static/topology.js"></script>
|
||||
{% endblock %}
|
||||
@@ -1,262 +0,0 @@
|
||||
import ipaddress
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .config import settings
|
||||
from .crypto import decrypt
|
||||
from .models import Peer
|
||||
|
||||
ONLINE_THRESHOLD_SECONDS = 180
|
||||
|
||||
|
||||
def _run(args: list[str], input_text: str | None = None) -> str:
|
||||
result = subprocess.run(
|
||||
args, input=input_text, capture_output=True, text=True, check=True
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def genkey() -> str:
|
||||
return _run(["wg", "genkey"])
|
||||
|
||||
|
||||
def genpsk() -> str:
|
||||
return _run(["wg", "genpsk"])
|
||||
|
||||
|
||||
def pubkey(private_key: str) -> str:
|
||||
return _run(["wg", "pubkey"], input_text=private_key)
|
||||
|
||||
|
||||
def generate_keypair() -> tuple[str, str]:
|
||||
private = genkey()
|
||||
return private, pubkey(private)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PeerStatus:
|
||||
public_key: str
|
||||
endpoint: str | None = None
|
||||
latest_handshake: datetime | None = None
|
||||
rx_bytes: int = 0
|
||||
tx_bytes: int = 0
|
||||
|
||||
@property
|
||||
def online(self) -> bool:
|
||||
if self.latest_handshake is None:
|
||||
return False
|
||||
delta = datetime.now(timezone.utc) - self.latest_handshake
|
||||
return delta.total_seconds() < ONLINE_THRESHOLD_SECONDS
|
||||
|
||||
|
||||
@dataclass
|
||||
class InterfaceStatus:
|
||||
name: str
|
||||
public_key: str | None = None
|
||||
listen_port: int | None = None
|
||||
up: bool = False
|
||||
peers: dict[str, PeerStatus] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def total_rx(self) -> int:
|
||||
return sum(p.rx_bytes for p in self.peers.values())
|
||||
|
||||
@property
|
||||
def total_tx(self) -> int:
|
||||
return sum(p.tx_bytes for p in self.peers.values())
|
||||
|
||||
|
||||
def get_status() -> InterfaceStatus:
|
||||
status = InterfaceStatus(name=settings.wg_interface)
|
||||
try:
|
||||
output = _run(["wg", "show", settings.wg_interface, "dump"])
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return status
|
||||
|
||||
status.up = True
|
||||
lines = output.splitlines()
|
||||
if lines:
|
||||
fields = lines[0].split("\t")
|
||||
if len(fields) >= 3:
|
||||
status.public_key = fields[1]
|
||||
status.listen_port = int(fields[2])
|
||||
for line in lines[1:]:
|
||||
fields = line.split("\t")
|
||||
if len(fields) < 8:
|
||||
continue
|
||||
peer = PeerStatus(public_key=fields[0])
|
||||
if fields[2] != "(none)":
|
||||
peer.endpoint = fields[2]
|
||||
handshake = int(fields[4])
|
||||
if handshake:
|
||||
peer.latest_handshake = datetime.fromtimestamp(handshake, tz=timezone.utc)
|
||||
peer.rx_bytes = int(fields[5])
|
||||
peer.tx_bytes = int(fields[6])
|
||||
status.peers[peer.public_key] = peer
|
||||
return status
|
||||
|
||||
|
||||
def _server_key_paths() -> tuple:
|
||||
key_dir = settings.data_dir / "server"
|
||||
return key_dir / "privatekey", key_dir / "publickey"
|
||||
|
||||
|
||||
def ensure_server_keys() -> tuple[str, str]:
|
||||
private_path, public_path = _server_key_paths()
|
||||
if private_path.exists() and public_path.exists():
|
||||
return private_path.read_text().strip(), public_path.read_text().strip()
|
||||
private_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
private, public = generate_keypair()
|
||||
private_path.touch(mode=0o600)
|
||||
private_path.write_text(private + "\n")
|
||||
public_path.write_text(public + "\n")
|
||||
return private, public
|
||||
|
||||
|
||||
def server_address() -> str:
|
||||
network = ipaddress.ip_network(settings.wg_subnet)
|
||||
return f"{next(network.hosts())}/{network.prefixlen}"
|
||||
|
||||
|
||||
def validate_address(address: str, taken: list[str]) -> str:
|
||||
network = ipaddress.ip_network(settings.wg_subnet)
|
||||
try:
|
||||
ip = ipaddress.ip_address(address.split("/")[0].strip())
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid IP address: {address}")
|
||||
if ip not in network:
|
||||
raise ValueError(f"{ip} is not in subnet {settings.wg_subnet}")
|
||||
if ip in (network.network_address, network.broadcast_address):
|
||||
raise ValueError(f"{ip} is not a usable host address")
|
||||
if ip == next(network.hosts()):
|
||||
raise ValueError(f"{ip} is reserved for the server")
|
||||
if ip in {ipaddress.ip_interface(a).ip for a in taken}:
|
||||
raise ValueError(f"{ip} is already assigned to another peer")
|
||||
return f"{ip}/32"
|
||||
|
||||
|
||||
def next_free_address(taken: list[str]) -> str:
|
||||
network = ipaddress.ip_network(settings.wg_subnet)
|
||||
used = {ipaddress.ip_interface(a).ip for a in taken}
|
||||
hosts = network.hosts()
|
||||
used.add(next(hosts))
|
||||
for host in hosts:
|
||||
if host not in used:
|
||||
return f"{host}/32"
|
||||
raise RuntimeError(f"No free addresses left in {settings.wg_subnet}")
|
||||
|
||||
|
||||
def validate_cidr_list(value: str) -> str:
|
||||
networks = []
|
||||
for part in value.split(","):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
try:
|
||||
networks.append(str(ipaddress.ip_network(part, strict=False)))
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid CIDR: {part}")
|
||||
return ", ".join(networks)
|
||||
|
||||
|
||||
def server_allowed_ips(peer: Peer) -> str:
|
||||
allowed = peer.address
|
||||
if peer.extra_allowed_ips:
|
||||
allowed += f", {peer.extra_allowed_ips}"
|
||||
return allowed
|
||||
|
||||
|
||||
def render_server_config(private_key: str, peers: list[Peer]) -> str:
|
||||
lines = [
|
||||
"[Interface]",
|
||||
f"PrivateKey = {private_key}",
|
||||
f"Address = {server_address()}",
|
||||
f"ListenPort = {settings.wg_port}",
|
||||
]
|
||||
# wg-quick adds routes for /32 peer addresses automatically; extra
|
||||
# site subnets need explicit routes so return traffic enters the tunnel.
|
||||
for peer in peers:
|
||||
if peer.enabled and peer.extra_allowed_ips:
|
||||
for subnet in peer.extra_allowed_ips.split(","):
|
||||
subnet = subnet.strip()
|
||||
lines.append(f"PostUp = ip route replace {subnet} dev %i")
|
||||
for peer in peers:
|
||||
if not peer.enabled:
|
||||
continue
|
||||
lines += [
|
||||
"",
|
||||
"[Peer]",
|
||||
f"# {peer.name}",
|
||||
f"PublicKey = {peer.public_key}",
|
||||
f"PresharedKey = {decrypt(peer.preshared_key_enc)}",
|
||||
f"AllowedIPs = {server_allowed_ips(peer)}",
|
||||
]
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def render_client_config(peer: Peer, server_public_key: str) -> str:
|
||||
return "\n".join(
|
||||
[
|
||||
"[Interface]",
|
||||
f"PrivateKey = {decrypt(peer.private_key_enc)}",
|
||||
f"Address = {peer.address}",
|
||||
f"DNS = {peer.dns or settings.wg_dns}",
|
||||
"",
|
||||
"[Peer]",
|
||||
f"PublicKey = {server_public_key}",
|
||||
f"PresharedKey = {decrypt(peer.preshared_key_enc)}",
|
||||
f"Endpoint = {settings.wg_host}:{settings.wg_port}",
|
||||
f"AllowedIPs = {peer.client_allowed_ips or settings.wg_allowed_ips}",
|
||||
f"PersistentKeepalive = {settings.wg_persistent_keepalive}",
|
||||
]
|
||||
) + "\n"
|
||||
|
||||
|
||||
def write_server_config(private_key: str, peers: list[Peer]) -> None:
|
||||
settings.wg_config_dir.mkdir(parents=True, exist_ok=True)
|
||||
config_path = settings.wg_config_dir / f"{settings.wg_interface}.conf"
|
||||
config_path.touch(mode=0o600)
|
||||
config_path.write_text(render_server_config(private_key, peers))
|
||||
|
||||
|
||||
def _is_managed(status: InterfaceStatus) -> bool:
|
||||
_, server_public = ensure_server_keys()
|
||||
return status.public_key == server_public
|
||||
|
||||
|
||||
def interface_up() -> None:
|
||||
status = get_status()
|
||||
if status.up and not _is_managed(status):
|
||||
raise RuntimeError(
|
||||
f"Interface {settings.wg_interface} is up but uses a foreign key; "
|
||||
"refusing to manage it. Set WG_INTERFACE to a dedicated interface."
|
||||
)
|
||||
if not status.up:
|
||||
_run(["wg-quick", "up", settings.wg_interface])
|
||||
|
||||
|
||||
def sync_routes(peers: list[Peer]) -> None:
|
||||
for peer in peers:
|
||||
if not peer.extra_allowed_ips:
|
||||
continue
|
||||
for subnet in peer.extra_allowed_ips.split(","):
|
||||
subnet = subnet.strip()
|
||||
args = ["ip", "route", "replace" if peer.enabled else "del", subnet]
|
||||
if peer.enabled:
|
||||
args += ["dev", settings.wg_interface]
|
||||
try:
|
||||
_run(args)
|
||||
except subprocess.CalledProcessError:
|
||||
pass
|
||||
|
||||
|
||||
def sync_peers(private_key: str, peers: list[Peer]) -> None:
|
||||
write_server_config(private_key, peers)
|
||||
status = get_status()
|
||||
if status.up and _is_managed(status):
|
||||
stripped = _run(
|
||||
["wg-quick", "strip", str(settings.wg_config_dir / f"{settings.wg_interface}.conf")]
|
||||
)
|
||||
_run(["wg", "syncconf", settings.wg_interface, "/dev/stdin"], input_text=stripped)
|
||||
sync_routes(peers)
|
||||
@@ -0,0 +1,75 @@
|
||||
from .addressing import (
|
||||
next_free_address,
|
||||
server_address,
|
||||
subnets_overlap,
|
||||
validate_address,
|
||||
validate_cidr_list,
|
||||
validate_interface_name,
|
||||
validate_subnet,
|
||||
)
|
||||
from .conf import (
|
||||
ParsedConfig,
|
||||
ParsedPeer,
|
||||
backup_config,
|
||||
config_path,
|
||||
discover_configs,
|
||||
parse_config,
|
||||
render_client_config,
|
||||
render_server_config,
|
||||
server_allowed_ips,
|
||||
write_server_config,
|
||||
)
|
||||
from .keys import generate_keypair, genkey, genpsk, pubkey
|
||||
from .status import InterfaceStatus, PeerStatus, get_status, system_interfaces
|
||||
from .sync import (
|
||||
interface_down,
|
||||
interface_up,
|
||||
is_managed,
|
||||
sync_isolation,
|
||||
sync_mss_clamp,
|
||||
sync_mtu,
|
||||
sync_nat,
|
||||
sync_peers,
|
||||
sync_routes,
|
||||
)
|
||||
from . import status as status_module
|
||||
from . import tuning
|
||||
|
||||
__all__ = [
|
||||
"InterfaceStatus",
|
||||
"ParsedConfig",
|
||||
"ParsedPeer",
|
||||
"PeerStatus",
|
||||
"backup_config",
|
||||
"config_path",
|
||||
"discover_configs",
|
||||
"generate_keypair",
|
||||
"genkey",
|
||||
"genpsk",
|
||||
"get_status",
|
||||
"interface_down",
|
||||
"interface_up",
|
||||
"is_managed",
|
||||
"next_free_address",
|
||||
"parse_config",
|
||||
"pubkey",
|
||||
"render_client_config",
|
||||
"render_server_config",
|
||||
"server_address",
|
||||
"server_allowed_ips",
|
||||
"subnets_overlap",
|
||||
"status_module",
|
||||
"sync_isolation",
|
||||
"sync_mss_clamp",
|
||||
"sync_mtu",
|
||||
"sync_nat",
|
||||
"sync_peers",
|
||||
"sync_routes",
|
||||
"system_interfaces",
|
||||
"tuning",
|
||||
"validate_address",
|
||||
"validate_cidr_list",
|
||||
"validate_interface_name",
|
||||
"validate_subnet",
|
||||
"write_server_config",
|
||||
]
|
||||
@@ -0,0 +1,73 @@
|
||||
import ipaddress
|
||||
import re
|
||||
|
||||
|
||||
def server_address(subnet: str) -> str:
|
||||
network = ipaddress.ip_network(subnet)
|
||||
return f"{next(network.hosts())}/{network.prefixlen}"
|
||||
|
||||
|
||||
def validate_interface_name(name: str) -> str:
|
||||
name = name.strip()
|
||||
if not re.fullmatch(r"[a-zA-Z0-9_=+.-]{1,15}", name):
|
||||
raise ValueError(f"Invalid interface name: {name}")
|
||||
return name
|
||||
|
||||
|
||||
def validate_subnet(subnet: str) -> str:
|
||||
try:
|
||||
network = ipaddress.ip_network(subnet.strip(), strict=True)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid subnet: {subnet}")
|
||||
if network.num_addresses < 4:
|
||||
raise ValueError(f"Subnet {network} is too small")
|
||||
return str(network)
|
||||
|
||||
|
||||
def subnets_overlap(subnet: str, others: list[str]) -> str | None:
|
||||
network = ipaddress.ip_network(subnet)
|
||||
for other in others:
|
||||
if network.overlaps(ipaddress.ip_network(other)):
|
||||
return other
|
||||
return None
|
||||
|
||||
|
||||
def validate_address(address: str, subnet: str, taken: list[str]) -> str:
|
||||
network = ipaddress.ip_network(subnet)
|
||||
try:
|
||||
ip = ipaddress.ip_address(address.split("/")[0].strip())
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid IP address: {address}")
|
||||
if ip not in network:
|
||||
raise ValueError(f"{ip} is not in subnet {subnet}")
|
||||
if ip in (network.network_address, network.broadcast_address):
|
||||
raise ValueError(f"{ip} is not a usable host address")
|
||||
if ip == next(network.hosts()):
|
||||
raise ValueError(f"{ip} is reserved for the server")
|
||||
if ip in {ipaddress.ip_interface(a).ip for a in taken}:
|
||||
raise ValueError(f"{ip} is already assigned to another peer")
|
||||
return f"{ip}/32"
|
||||
|
||||
|
||||
def next_free_address(subnet: str, taken: list[str]) -> str:
|
||||
network = ipaddress.ip_network(subnet)
|
||||
used = {ipaddress.ip_interface(a).ip for a in taken}
|
||||
hosts = network.hosts()
|
||||
used.add(next(hosts))
|
||||
for host in hosts:
|
||||
if host not in used:
|
||||
return f"{host}/32"
|
||||
raise RuntimeError(f"No free addresses left in {subnet}")
|
||||
|
||||
|
||||
def validate_cidr_list(value: str) -> str:
|
||||
networks = []
|
||||
for part in value.split(","):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
try:
|
||||
networks.append(str(ipaddress.ip_network(part, strict=False)))
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid CIDR: {part}")
|
||||
return ", ".join(networks)
|
||||
@@ -0,0 +1,188 @@
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from ..config import settings
|
||||
from ..crypto import decrypt
|
||||
from ..models import Interface, Peer
|
||||
from .addressing import server_address
|
||||
|
||||
|
||||
def config_path(name: str) -> Path:
|
||||
return settings.wg_config_dir / f"{name}.conf"
|
||||
|
||||
|
||||
def discover_configs() -> list[str]:
|
||||
if not settings.wg_config_dir.exists():
|
||||
return []
|
||||
return sorted(p.stem for p in settings.wg_config_dir.glob("*.conf"))
|
||||
|
||||
|
||||
def server_allowed_ips(peer: Peer) -> str:
|
||||
allowed = peer.address
|
||||
if peer.extra_allowed_ips:
|
||||
allowed += f", {peer.extra_allowed_ips}"
|
||||
return allowed
|
||||
|
||||
|
||||
def render_server_config(iface: Interface, peers: list[Peer]) -> str:
|
||||
lines = [
|
||||
"[Interface]",
|
||||
f"PrivateKey = {decrypt(iface.private_key_enc)}",
|
||||
f"Address = {server_address(iface.subnet)}",
|
||||
f"ListenPort = {iface.listen_port}",
|
||||
]
|
||||
if iface.mtu:
|
||||
lines.append(f"MTU = {iface.mtu}")
|
||||
if iface.fwmark:
|
||||
lines.append(f"FwMark = {iface.fwmark}")
|
||||
if iface.route_table:
|
||||
lines.append(f"Table = {iface.route_table}")
|
||||
if iface.mss_clamp:
|
||||
rule = (
|
||||
"-o %i -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu"
|
||||
)
|
||||
lines.append(f"PostUp = iptables -t mangle -A FORWARD {rule}")
|
||||
lines.append(f"PostDown = iptables -t mangle -D FORWARD {rule} || true")
|
||||
# wg-quick adds routes for /32 peer addresses automatically; extra
|
||||
# site subnets need explicit routes so return traffic enters the tunnel.
|
||||
for peer in peers:
|
||||
if peer.enabled and peer.extra_allowed_ips:
|
||||
for subnet in peer.extra_allowed_ips.split(","):
|
||||
subnet = subnet.strip()
|
||||
lines.append(f"PostUp = ip route replace {subnet} dev %i")
|
||||
for command in (iface.post_up or "").splitlines():
|
||||
command = command.strip()
|
||||
if command:
|
||||
lines.append(f"PostUp = {command}")
|
||||
for command in (iface.post_down or "").splitlines():
|
||||
command = command.strip()
|
||||
if command:
|
||||
lines.append(f"PostDown = {command}")
|
||||
for peer in peers:
|
||||
if not peer.enabled:
|
||||
continue
|
||||
lines += [
|
||||
"",
|
||||
"[Peer]",
|
||||
f"# {peer.name}",
|
||||
f"PublicKey = {peer.public_key}",
|
||||
]
|
||||
if peer.preshared_key_enc:
|
||||
lines.append(f"PresharedKey = {decrypt(peer.preshared_key_enc)}")
|
||||
lines.append(f"AllowedIPs = {server_allowed_ips(peer)}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def render_client_config(peer: Peer, iface: Interface) -> str:
|
||||
if not peer.has_private_key:
|
||||
raise ValueError(
|
||||
"Peer has no private key (imported). Rotate keys to generate a config."
|
||||
)
|
||||
lines = [
|
||||
"[Interface]",
|
||||
f"PrivateKey = {decrypt(peer.private_key_enc)}",
|
||||
f"Address = {peer.address}",
|
||||
f"DNS = {peer.dns or iface.dns}",
|
||||
]
|
||||
if iface.mtu:
|
||||
lines.append(f"MTU = {iface.mtu}")
|
||||
lines += [
|
||||
"",
|
||||
"[Peer]",
|
||||
f"PublicKey = {iface.public_key}",
|
||||
]
|
||||
if peer.preshared_key_enc:
|
||||
lines.append(f"PresharedKey = {decrypt(peer.preshared_key_enc)}")
|
||||
keepalive = (
|
||||
peer.persistent_keepalive
|
||||
if peer.persistent_keepalive is not None
|
||||
else iface.persistent_keepalive
|
||||
)
|
||||
lines += [
|
||||
f"Endpoint = {iface.host}:{iface.listen_port}",
|
||||
f"AllowedIPs = {peer.client_allowed_ips or iface.allowed_ips}",
|
||||
]
|
||||
if keepalive:
|
||||
lines.append(f"PersistentKeepalive = {keepalive}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def write_server_config(iface: Interface, peers: list[Peer]) -> None:
|
||||
settings.wg_config_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = config_path(iface.name)
|
||||
path.touch(mode=0o600)
|
||||
path.write_text(render_server_config(iface, peers))
|
||||
|
||||
|
||||
def backup_config(name: str) -> Path | None:
|
||||
path = config_path(name)
|
||||
if not path.exists():
|
||||
return None
|
||||
stamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
||||
backup = path.with_name(f"{name}.conf.bak-{stamp}")
|
||||
backup.write_bytes(path.read_bytes())
|
||||
backup.chmod(0o600)
|
||||
return backup
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedPeer:
|
||||
public_key: str = ""
|
||||
preshared_key: str = ""
|
||||
allowed_ips: list[str] = field(default_factory=list)
|
||||
name: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedConfig:
|
||||
private_key: str = ""
|
||||
address: str = ""
|
||||
listen_port: int | None = None
|
||||
peers: list[ParsedPeer] = field(default_factory=list)
|
||||
|
||||
|
||||
def parse_config(text: str) -> ParsedConfig:
|
||||
parsed = ParsedConfig()
|
||||
section = ""
|
||||
current: ParsedPeer | None = None
|
||||
pending_comment = ""
|
||||
for raw in text.splitlines():
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("#"):
|
||||
comment = line.lstrip("# ").strip()
|
||||
if section == "peer" and current is not None and not current.name:
|
||||
current.name = comment
|
||||
else:
|
||||
pending_comment = comment
|
||||
continue
|
||||
if line.startswith("["):
|
||||
section = line.strip("[]").lower()
|
||||
if section == "peer":
|
||||
current = ParsedPeer(name=pending_comment)
|
||||
parsed.peers.append(current)
|
||||
pending_comment = ""
|
||||
continue
|
||||
if "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip().lower()
|
||||
value = value.strip()
|
||||
if section == "interface":
|
||||
if key == "privatekey":
|
||||
parsed.private_key = value
|
||||
elif key == "address":
|
||||
parsed.address = value.split(",")[0].strip()
|
||||
elif key == "listenport":
|
||||
parsed.listen_port = int(value)
|
||||
elif section == "peer" and current is not None:
|
||||
if key == "publickey":
|
||||
current.public_key = value
|
||||
elif key == "presharedkey":
|
||||
current.preshared_key = value
|
||||
elif key == "allowedips":
|
||||
current.allowed_ips += [v.strip() for v in value.split(",") if v.strip()]
|
||||
parsed.peers = [p for p in parsed.peers if p.public_key]
|
||||
return parsed
|
||||
@@ -0,0 +1,24 @@
|
||||
import base64
|
||||
import secrets
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
|
||||
|
||||
|
||||
def genkey() -> str:
|
||||
private = X25519PrivateKey.generate()
|
||||
return base64.b64encode(private.private_bytes_raw()).decode()
|
||||
|
||||
|
||||
def genpsk() -> str:
|
||||
return base64.b64encode(secrets.token_bytes(32)).decode()
|
||||
|
||||
|
||||
def pubkey(private_key: str) -> str:
|
||||
raw = base64.b64decode(private_key)
|
||||
public = X25519PrivateKey.from_private_bytes(raw).public_key()
|
||||
return base64.b64encode(public.public_bytes_raw()).decode()
|
||||
|
||||
|
||||
def generate_keypair() -> tuple[str, str]:
|
||||
private = genkey()
|
||||
return private, pubkey(private)
|
||||
@@ -0,0 +1,8 @@
|
||||
import subprocess
|
||||
|
||||
|
||||
def run(args: list[str], input_text: str | None = None) -> str:
|
||||
result = subprocess.run(
|
||||
args, input=input_text, capture_output=True, text=True, check=True
|
||||
)
|
||||
return result.stdout.strip()
|
||||
@@ -0,0 +1,81 @@
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .runner import run
|
||||
|
||||
DEFAULT_ONLINE_THRESHOLD_SECONDS = 180
|
||||
|
||||
# Adjustable at runtime from the settings page.
|
||||
online_threshold_seconds = DEFAULT_ONLINE_THRESHOLD_SECONDS
|
||||
|
||||
|
||||
@dataclass
|
||||
class PeerStatus:
|
||||
public_key: str
|
||||
endpoint: str | None = None
|
||||
latest_handshake: datetime | None = None
|
||||
rx_bytes: int = 0
|
||||
tx_bytes: int = 0
|
||||
|
||||
@property
|
||||
def online(self) -> bool:
|
||||
if self.latest_handshake is None:
|
||||
return False
|
||||
delta = datetime.now(timezone.utc) - self.latest_handshake
|
||||
return delta.total_seconds() < online_threshold_seconds
|
||||
|
||||
|
||||
@dataclass
|
||||
class InterfaceStatus:
|
||||
name: str
|
||||
public_key: str | None = None
|
||||
listen_port: int | None = None
|
||||
up: bool = False
|
||||
peers: dict[str, PeerStatus] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def total_rx(self) -> int:
|
||||
return sum(p.rx_bytes for p in self.peers.values())
|
||||
|
||||
@property
|
||||
def total_tx(self) -> int:
|
||||
return sum(p.tx_bytes for p in self.peers.values())
|
||||
|
||||
|
||||
def get_status(name: str) -> InterfaceStatus:
|
||||
status = InterfaceStatus(name=name)
|
||||
try:
|
||||
output = run(["wg", "show", name, "dump"])
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return status
|
||||
|
||||
status.up = True
|
||||
lines = output.splitlines()
|
||||
if lines:
|
||||
fields = lines[0].split("\t")
|
||||
if len(fields) >= 3:
|
||||
status.public_key = fields[1]
|
||||
status.listen_port = int(fields[2])
|
||||
for line in lines[1:]:
|
||||
fields = line.split("\t")
|
||||
if len(fields) < 8:
|
||||
continue
|
||||
peer = PeerStatus(public_key=fields[0])
|
||||
if fields[2] != "(none)":
|
||||
peer.endpoint = fields[2]
|
||||
handshake = int(fields[4])
|
||||
if handshake:
|
||||
peer.latest_handshake = datetime.fromtimestamp(handshake, tz=timezone.utc)
|
||||
peer.rx_bytes = int(fields[5])
|
||||
peer.tx_bytes = int(fields[6])
|
||||
status.peers[peer.public_key] = peer
|
||||
return status
|
||||
|
||||
|
||||
def system_interfaces() -> list[str]:
|
||||
try:
|
||||
output = run(["wg", "show", "interfaces"])
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return []
|
||||
return output.split()
|
||||
@@ -0,0 +1,118 @@
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
from ..models import Interface, Peer
|
||||
from .conf import config_path, write_server_config
|
||||
from .runner import run
|
||||
from .status import InterfaceStatus, get_status
|
||||
|
||||
|
||||
def is_managed(iface: Interface, status: InterfaceStatus) -> bool:
|
||||
return status.public_key == iface.public_key
|
||||
|
||||
|
||||
def interface_up(iface: Interface) -> None:
|
||||
status = get_status(iface.name)
|
||||
if status.up and not is_managed(iface, status):
|
||||
raise RuntimeError(
|
||||
f"Interface {iface.name} is up but uses a foreign key; "
|
||||
"refusing to manage it. Import it first or pick another name."
|
||||
)
|
||||
if not status.up:
|
||||
run(["wg-quick", "up", str(config_path(iface.name))])
|
||||
sync_nat(iface)
|
||||
sync_isolation(iface)
|
||||
sync_mss_clamp(iface)
|
||||
|
||||
|
||||
def interface_down(iface: Interface) -> None:
|
||||
status = get_status(iface.name)
|
||||
if status.up and is_managed(iface, status):
|
||||
run(["wg-quick", "down", str(config_path(iface.name))])
|
||||
|
||||
|
||||
def sync_routes(iface: Interface, peers: list[Peer]) -> None:
|
||||
for peer in peers:
|
||||
if not peer.extra_allowed_ips:
|
||||
continue
|
||||
for subnet in peer.extra_allowed_ips.split(","):
|
||||
subnet = subnet.strip()
|
||||
args = ["ip", "route", "replace" if peer.enabled else "del", subnet]
|
||||
if peer.enabled:
|
||||
args += ["dev", iface.name]
|
||||
try:
|
||||
run(args)
|
||||
except subprocess.CalledProcessError:
|
||||
pass
|
||||
|
||||
|
||||
def sync_nat(iface: Interface) -> None:
|
||||
try:
|
||||
route = run(["ip", "route", "show", "default"])
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return
|
||||
match = re.search(r"\bdev\s+(\S+)", route)
|
||||
if not match:
|
||||
return
|
||||
rule = ["POSTROUTING", "-s", iface.subnet, "-o", match.group(1), "-j", "MASQUERADE"]
|
||||
try:
|
||||
run(["iptables", "-t", "nat", "-C", *rule])
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except subprocess.CalledProcessError:
|
||||
try:
|
||||
run(["iptables", "-t", "nat", "-A", *rule])
|
||||
except subprocess.CalledProcessError:
|
||||
pass
|
||||
|
||||
|
||||
def sync_isolation(iface: Interface) -> None:
|
||||
rule = ["-i", iface.name, "-o", iface.name, "-j", "DROP"]
|
||||
try:
|
||||
if iface.peer_isolation:
|
||||
try:
|
||||
run(["iptables", "-C", "FORWARD", *rule])
|
||||
except subprocess.CalledProcessError:
|
||||
run(["iptables", "-I", "FORWARD", "1", *rule])
|
||||
else:
|
||||
run(["iptables", "-D", "FORWARD", *rule])
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
pass
|
||||
|
||||
|
||||
def sync_mss_clamp(iface: Interface) -> None:
|
||||
rule = [
|
||||
"-o", iface.name, "-p", "tcp", "--tcp-flags", "SYN,RST", "SYN",
|
||||
"-j", "TCPMSS", "--clamp-mss-to-pmtu",
|
||||
]
|
||||
try:
|
||||
if iface.mss_clamp:
|
||||
try:
|
||||
run(["iptables", "-t", "mangle", "-C", "FORWARD", *rule])
|
||||
except subprocess.CalledProcessError:
|
||||
run(["iptables", "-t", "mangle", "-A", "FORWARD", *rule])
|
||||
else:
|
||||
run(["iptables", "-t", "mangle", "-D", "FORWARD", *rule])
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
pass
|
||||
|
||||
|
||||
def sync_mtu(iface: Interface) -> None:
|
||||
if not iface.mtu:
|
||||
return
|
||||
try:
|
||||
run(["ip", "link", "set", "dev", iface.name, "mtu", str(iface.mtu)])
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
pass
|
||||
|
||||
|
||||
def sync_peers(iface: Interface, peers: list[Peer]) -> None:
|
||||
write_server_config(iface, peers)
|
||||
status = get_status(iface.name)
|
||||
if status.up and is_managed(iface, status):
|
||||
stripped = run(["wg-quick", "strip", str(config_path(iface.name))])
|
||||
run(["wg", "syncconf", iface.name, "/dev/stdin"], input_text=stripped)
|
||||
sync_routes(iface, peers)
|
||||
sync_mtu(iface)
|
||||
sync_isolation(iface)
|
||||
sync_mss_clamp(iface)
|
||||
@@ -0,0 +1,92 @@
|
||||
import re
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .runner import run
|
||||
|
||||
|
||||
def _sysctl_get(key: str) -> str:
|
||||
try:
|
||||
return run(["sysctl", "-n", key])
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return ""
|
||||
|
||||
|
||||
def _sysctl_set(key: str, value: str) -> bool:
|
||||
try:
|
||||
run(["sysctl", "-w", f"{key}={value}"])
|
||||
return True
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return False
|
||||
|
||||
|
||||
def default_out_interface() -> str:
|
||||
try:
|
||||
route = run(["ip", "route", "show", "default"])
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return ""
|
||||
match = re.search(r"\bdev\s+(\S+)", route)
|
||||
return match.group(1) if match else ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class HostTuning:
|
||||
rmem_max: str = ""
|
||||
wmem_max: str = ""
|
||||
netdev_max_backlog: str = ""
|
||||
ip_forward: str = ""
|
||||
gro_forwarding: str = ""
|
||||
out_interface: str = ""
|
||||
|
||||
|
||||
def read_host_tuning() -> HostTuning:
|
||||
tuning = HostTuning(
|
||||
rmem_max=_sysctl_get("net.core.rmem_max"),
|
||||
wmem_max=_sysctl_get("net.core.wmem_max"),
|
||||
netdev_max_backlog=_sysctl_get("net.core.netdev_max_backlog"),
|
||||
ip_forward=_sysctl_get("net.ipv4.ip_forward"),
|
||||
out_interface=default_out_interface(),
|
||||
)
|
||||
if tuning.out_interface:
|
||||
try:
|
||||
output = run(["ethtool", "-k", tuning.out_interface])
|
||||
match = re.search(r"rx-udp-gro-forwarding:\s*(\S+)", output)
|
||||
if match:
|
||||
tuning.gro_forwarding = match.group(1)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
pass
|
||||
return tuning
|
||||
|
||||
|
||||
def apply_udp_buffers(size_bytes: int) -> list[str]:
|
||||
errors = []
|
||||
for key in ("net.core.rmem_max", "net.core.wmem_max"):
|
||||
if not _sysctl_set(key, str(size_bytes)):
|
||||
errors.append(f"failed to set {key}")
|
||||
return errors
|
||||
|
||||
|
||||
def apply_backlog(value: int) -> list[str]:
|
||||
if _sysctl_set("net.core.netdev_max_backlog", str(value)):
|
||||
return []
|
||||
return ["failed to set net.core.netdev_max_backlog"]
|
||||
|
||||
|
||||
def apply_gro_forwarding(enable: bool) -> list[str]:
|
||||
device = default_out_interface()
|
||||
if not device:
|
||||
return ["no default route interface found"]
|
||||
state = "on" if enable else "off"
|
||||
try:
|
||||
run(
|
||||
[
|
||||
"ethtool", "-K", device,
|
||||
"rx-udp-gro-forwarding", state,
|
||||
"rx-gro-list", "off" if enable else "on",
|
||||
]
|
||||
)
|
||||
return []
|
||||
except FileNotFoundError:
|
||||
return ["ethtool not installed"]
|
||||
except subprocess.CalledProcessError as exc:
|
||||
return [f"ethtool failed on {device}: {exc.stderr.strip() or exc}"]
|
||||
Reference in New Issue
Block a user