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
@@ -4,4 +4,5 @@ __pycache__/
|
|||||||
data/
|
data/
|
||||||
wg-admin-data/
|
wg-admin-data/
|
||||||
wg-config/
|
wg-config/
|
||||||
|
volumes/
|
||||||
.env
|
.env
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
FROM python:3.13-slim
|
FROM python:3.13-slim
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends wireguard-tools iptables iproute2 \
|
&& apt-get install -y --no-install-recommends wireguard-tools iptables iproute2 procps ethtool \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /opt/wireguard-admin
|
WORKDIR /opt/wireguard-admin
|
||||||
|
|||||||
@@ -35,13 +35,99 @@ PEER_MIGRATIONS = {
|
|||||||
"last_tx": "ALTER TABLE peers ADD COLUMN last_tx INTEGER NOT NULL DEFAULT 0",
|
"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:
|
def run_migrations() -> None:
|
||||||
with engine.connect() as conn:
|
with engine.connect() as conn:
|
||||||
existing = {
|
peer_columns = _columns(conn, "peers")
|
||||||
row[1] for row in conn.execute(text("PRAGMA table_info(peers)"))
|
|
||||||
}
|
|
||||||
for column, ddl in PEER_MIGRATIONS.items():
|
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))
|
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()
|
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 .config import settings
|
||||||
from .db import Base, SessionLocal, engine, get_db, run_migrations
|
from .db import Base, SessionLocal, engine, get_db, run_migrations
|
||||||
|
|
||||||
TRAFFIC_SAMPLE_INTERVAL = 60
|
PRUNE_EVERY_TICKS = 60
|
||||||
|
|
||||||
templates = Jinja2Templates(directory="app/templates")
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
|
|
||||||
|
|
||||||
async def _background_sampler() -> None:
|
async def _background_sampler() -> None:
|
||||||
|
tick = 0
|
||||||
while True:
|
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()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
service.accumulate_usage(db)
|
service.accumulate_usage(db)
|
||||||
service.disable_expired_peers(db)
|
service.disable_expired_peers(db)
|
||||||
service.sample_traffic(db)
|
service.sample_traffic(db)
|
||||||
|
tick += 1
|
||||||
|
if tick % PRUNE_EVERY_TICKS == 0:
|
||||||
|
service.prune_traffic_samples(db)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
@@ -41,11 +52,12 @@ async def lifespan(app: FastAPI):
|
|||||||
run_migrations()
|
run_migrations()
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
service.apply_config(db)
|
service.bootstrap_default_interface(db)
|
||||||
try:
|
wireguard.status_module.online_threshold_seconds = (
|
||||||
wireguard.interface_up()
|
service.get_runtime_settings(db)["online_threshold"]
|
||||||
except Exception:
|
)
|
||||||
pass
|
service.prune_traffic_samples(db)
|
||||||
|
service.apply_all_configs(db)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
task = asyncio.create_task(_background_sampler())
|
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.filters["fmt_bytes"] = _fmt_bytes
|
||||||
|
templates.env.globals["server_address"] = wireguard.server_address
|
||||||
|
|
||||||
|
|
||||||
@app.get("/login", response_class=HTMLResponse)
|
@app.get("/login", response_class=HTMLResponse)
|
||||||
@@ -97,30 +110,164 @@ def logout():
|
|||||||
return response
|
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])
|
@app.get("/", response_class=HTMLResponse, dependencies=[logged_in])
|
||||||
def dashboard(request: Request, db: Session = Depends(get_db)):
|
def dashboard(request: Request, db: Session = Depends(get_db)):
|
||||||
status = wireguard.get_status()
|
return templates.TemplateResponse(
|
||||||
peers = service.list_peers(db)
|
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(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"dashboard.html",
|
"interfaces.html",
|
||||||
{
|
{
|
||||||
"status": status,
|
"overview": _interface_overview(db),
|
||||||
"peers": peers,
|
"candidates": service.import_candidates(db),
|
||||||
|
"error": error,
|
||||||
"settings": settings,
|
"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])
|
@app.get("/peers", response_class=HTMLResponse, dependencies=[logged_in])
|
||||||
def peers_page(request: Request, db: Session = Depends(get_db), error: str = ""):
|
def peers_page(
|
||||||
status = wireguard.get_status()
|
request: Request,
|
||||||
peers = service.list_peers(db)
|
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(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"peers.html",
|
"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])
|
@app.post("/peers", dependencies=[logged_in])
|
||||||
def create_peer(
|
def create_peer(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
interface_id: int = Form(...),
|
||||||
name: str = Form(...),
|
name: str = Form(...),
|
||||||
expires_at: str = Form(""),
|
expires_at: str = Form(""),
|
||||||
note: str = Form(""),
|
note: str = Form(""),
|
||||||
@@ -142,16 +290,18 @@ def create_peer(
|
|||||||
extra_allowed_ips: str = Form(""),
|
extra_allowed_ips: str = Form(""),
|
||||||
client_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
|
expiry = datetime.fromisoformat(expires_at) if expires_at else None
|
||||||
quota = _parse_quota_gib(quota_gib)
|
quota = _parse_quota_gib(quota_gib)
|
||||||
try:
|
try:
|
||||||
if count > 1:
|
if count > 1:
|
||||||
service.create_peers_batch(
|
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(
|
peer = service.create_peer(
|
||||||
db,
|
db,
|
||||||
|
iface,
|
||||||
name.strip(),
|
name.strip(),
|
||||||
expiry,
|
expiry,
|
||||||
note.strip(),
|
note.strip(),
|
||||||
@@ -162,7 +312,9 @@ def create_peer(
|
|||||||
client_allowed_ips.strip(),
|
client_allowed_ips.strip(),
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
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)
|
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 = ""
|
request: Request, peer_id: int, db: Session = Depends(get_db), error: str = ""
|
||||||
):
|
):
|
||||||
peer = _get_peer_or_404(db, peer_id)
|
peer = _get_peer_or_404(db, peer_id)
|
||||||
status = wireguard.get_status()
|
iface = peer.interface
|
||||||
_, server_public = wireguard.ensure_server_keys()
|
status = wireguard.get_status(iface.name)
|
||||||
client_config = wireguard.render_client_config(peer, server_public)
|
client_config = (
|
||||||
|
wireguard.render_client_config(peer, iface) if peer.has_private_key else None
|
||||||
|
)
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"peer_detail.html",
|
"peer_detail.html",
|
||||||
{
|
{
|
||||||
"peer": peer,
|
"peer": peer,
|
||||||
|
"iface": iface,
|
||||||
"peer_status": status.peers.get(peer.public_key),
|
"peer_status": status.peers.get(peer.public_key),
|
||||||
"client_config": client_config,
|
"client_config": client_config,
|
||||||
"error": error,
|
"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(""),
|
dns: str = Form(""),
|
||||||
extra_allowed_ips: str = Form(""),
|
extra_allowed_ips: str = Form(""),
|
||||||
client_allowed_ips: str = Form(""),
|
client_allowed_ips: str = Form(""),
|
||||||
|
persistent_keepalive: str = Form(""),
|
||||||
):
|
):
|
||||||
peer = _get_peer_or_404(db, peer_id)
|
peer = _get_peer_or_404(db, peer_id)
|
||||||
try:
|
try:
|
||||||
|
keepalive = int(persistent_keepalive) if persistent_keepalive.strip() else None
|
||||||
service.update_peer(
|
service.update_peer(
|
||||||
db,
|
db,
|
||||||
peer,
|
peer,
|
||||||
@@ -214,6 +371,7 @@ def update_peer(
|
|||||||
dns.strip(),
|
dns.strip(),
|
||||||
extra_allowed_ips.strip(),
|
extra_allowed_ips.strip(),
|
||||||
client_allowed_ips.strip(),
|
client_allowed_ips.strip(),
|
||||||
|
keepalive,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return RedirectResponse(
|
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])
|
@app.post("/peers/{peer_id}/toggle", dependencies=[logged_in])
|
||||||
def toggle_peer(peer_id: int, db: Session = Depends(get_db)):
|
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)
|
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])
|
@app.post("/peers/{peer_id}/delete", dependencies=[logged_in])
|
||||||
def delete_peer(peer_id: int, db: Session = Depends(get_db)):
|
def delete_peer(peer_id: int, db: Session = Depends(get_db)):
|
||||||
service.delete_peer(db, _get_peer_or_404(db, peer_id))
|
peer = _get_peer_or_404(db, peer_id)
|
||||||
return RedirectResponse("/peers", status_code=303)
|
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])
|
@app.get("/peers/{peer_id}/config", dependencies=[logged_in])
|
||||||
def peer_config(peer_id: int, db: Session = Depends(get_db)):
|
def peer_config(peer_id: int, db: Session = Depends(get_db)):
|
||||||
peer = _get_peer_or_404(db, peer_id)
|
peer = _get_peer_or_404(db, peer_id)
|
||||||
_, server_public = wireguard.ensure_server_keys()
|
if not peer.has_private_key:
|
||||||
config = wireguard.render_client_config(peer, server_public)
|
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(
|
return Response(
|
||||||
config,
|
config,
|
||||||
media_type="text/plain",
|
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])
|
@app.get("/peers/{peer_id}/qr", dependencies=[logged_in])
|
||||||
def peer_qr(peer_id: int, db: Session = Depends(get_db)):
|
def peer_qr(peer_id: int, db: Session = Depends(get_db)):
|
||||||
peer = _get_peer_or_404(db, peer_id)
|
peer = _get_peer_or_404(db, peer_id)
|
||||||
_, server_public = wireguard.ensure_server_keys()
|
if not peer.has_private_key:
|
||||||
config = wireguard.render_client_config(peer, server_public)
|
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)
|
image = qrcode.make(config)
|
||||||
buffer = io.BytesIO()
|
buffer = io.BytesIO()
|
||||||
image.save(buffer, format="PNG")
|
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")
|
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])
|
@app.get("/settings", response_class=HTMLResponse, dependencies=[logged_in])
|
||||||
def settings_page(request: Request):
|
def settings_page(
|
||||||
_, server_public = wireguard.ensure_server_keys()
|
request: Request, db: Session = Depends(get_db), error: str = "", message: str = ""
|
||||||
|
):
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"settings.html",
|
"settings.html",
|
||||||
{
|
{
|
||||||
"settings": settings,
|
"settings": settings,
|
||||||
"server_public": server_public,
|
"interfaces": service.list_interfaces(db),
|
||||||
"server_address": wireguard.server_address(),
|
"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])
|
@app.get("/api/status", dependencies=[logged_in])
|
||||||
def api_status(db: Session = Depends(get_db)):
|
def api_status(db: Session = Depends(get_db)):
|
||||||
status = wireguard.get_status()
|
runtime = service.get_runtime_settings(db)
|
||||||
peers = service.list_peers(db)
|
interfaces = []
|
||||||
return {
|
for iface in service.list_interfaces(db):
|
||||||
"interface": {
|
status = wireguard.get_status(iface.name)
|
||||||
"name": status.name,
|
peers = service.list_peers(db, iface)
|
||||||
"up": status.up,
|
interfaces.append(
|
||||||
"listen_port": status.listen_port,
|
|
||||||
"total_rx": status.total_rx,
|
|
||||||
"total_tx": status.total_tx,
|
|
||||||
},
|
|
||||||
"peers": [
|
|
||||||
{
|
{
|
||||||
"id": peer.id,
|
"id": iface.id,
|
||||||
"name": peer.name,
|
"name": iface.name,
|
||||||
"address": peer.address,
|
"up": status.up,
|
||||||
"enabled": peer.enabled,
|
"enabled": iface.enabled,
|
||||||
"note": peer.note,
|
"imported": iface.imported,
|
||||||
"extra_allowed_ips": peer.extra_allowed_ips,
|
"listen_port": status.listen_port or iface.listen_port,
|
||||||
"client_allowed_ips": peer.client_allowed_ips,
|
"address": wireguard.server_address(iface.subnet),
|
||||||
"quota_bytes": peer.quota_bytes,
|
"subnet": iface.subnet,
|
||||||
"cum_rx": peer.cum_rx,
|
"host": iface.host,
|
||||||
"cum_tx": peer.cum_tx,
|
"peer_isolation": iface.peer_isolation,
|
||||||
"over_quota": peer.over_quota,
|
"total_rx": status.total_rx,
|
||||||
"online": (ps := status.peers.get(peer.public_key)) is not None and ps.online,
|
"total_tx": status.total_tx,
|
||||||
"endpoint": ps.endpoint if ps else None,
|
"peers": [
|
||||||
"latest_handshake": ps.latest_handshake.isoformat()
|
{
|
||||||
if ps and ps.latest_handshake
|
"id": peer.id,
|
||||||
else None,
|
"name": peer.name,
|
||||||
"rx_bytes": ps.rx_bytes if ps else 0,
|
"address": peer.address,
|
||||||
"tx_bytes": ps.tx_bytes if ps else 0,
|
"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 datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, Integer, String
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, UniqueConstraint
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from .db import Base
|
from .db import Base
|
||||||
|
|
||||||
@@ -10,15 +10,51 @@ def utcnow() -> datetime:
|
|||||||
return datetime.now(timezone.utc)
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
class Peer(Base):
|
class Interface(Base):
|
||||||
__tablename__ = "peers"
|
__tablename__ = "interfaces"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
name: Mapped[str] = mapped_column(String(64), unique=True)
|
name: Mapped[str] = mapped_column(String(15), unique=True)
|
||||||
public_key: Mapped[str] = mapped_column(String(64), 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))
|
private_key_enc: Mapped[str] = mapped_column(String(256))
|
||||||
preshared_key_enc: Mapped[str] = mapped_column(String(256))
|
public_key: Mapped[str] = mapped_column(String(64), unique=True)
|
||||||
address: 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)
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
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="")
|
dns: Mapped[str] = mapped_column(String(128), default="")
|
||||||
extra_allowed_ips: Mapped[str] = mapped_column(String(512), default="")
|
extra_allowed_ips: Mapped[str] = mapped_column(String(512), default="")
|
||||||
client_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)
|
quota_bytes: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
cum_rx: 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)
|
cum_tx: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
last_rx: 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)
|
last_tx: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
|
interface: Mapped[Interface] = relationship(back_populates="peers")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cum_total(self) -> int:
|
def cum_total(self) -> int:
|
||||||
return self.cum_rx + self.cum_tx
|
return self.cum_rx + self.cum_tx
|
||||||
@@ -40,11 +79,23 @@ class Peer(Base):
|
|||||||
def over_quota(self) -> bool:
|
def over_quota(self) -> bool:
|
||||||
return self.quota_bytes > 0 and self.cum_total >= self.quota_bytes
|
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):
|
class TrafficSample(Base):
|
||||||
__tablename__ = "traffic_samples"
|
__tablename__ = "traffic_samples"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
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)
|
peer_public_key: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
rx_bytes: Mapped[int] = mapped_column(Integer, default=0)
|
rx_bytes: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
tx_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 import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from . import wireguard
|
from . import wireguard
|
||||||
|
from .config import settings
|
||||||
from .crypto import encrypt
|
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]:
|
def get_runtime_settings(db: Session) -> dict[str, int]:
|
||||||
return list(db.scalars(select(Peer).order_by(Peer.id)))
|
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:
|
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(
|
def create_peer(
|
||||||
db: Session,
|
db: Session,
|
||||||
|
iface: Interface,
|
||||||
name: str,
|
name: str,
|
||||||
expires_at: datetime | None = None,
|
expires_at: datetime | None = None,
|
||||||
note: str = "",
|
note: str = "",
|
||||||
@@ -27,14 +389,17 @@ def create_peer(
|
|||||||
extra_allowed_ips: str = "",
|
extra_allowed_ips: str = "",
|
||||||
client_allowed_ips: str = "",
|
client_allowed_ips: str = "",
|
||||||
) -> Peer:
|
) -> 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()
|
private, public = wireguard.generate_keypair()
|
||||||
psk = wireguard.genpsk()
|
psk = wireguard.genpsk()
|
||||||
taken = [p.address for p in list_peers(db)]
|
taken = [p.address for p in list_peers(db, iface)]
|
||||||
if address:
|
if address:
|
||||||
address = wireguard.validate_address(address, taken)
|
address = wireguard.validate_address(address, iface.subnet, taken)
|
||||||
else:
|
else:
|
||||||
address = wireguard.next_free_address(taken)
|
address = wireguard.next_free_address(iface.subnet, taken)
|
||||||
peer = Peer(
|
peer = Peer(
|
||||||
|
interface_id=iface.id,
|
||||||
name=name,
|
name=name,
|
||||||
public_key=public,
|
public_key=public,
|
||||||
private_key_enc=encrypt(private),
|
private_key_enc=encrypt(private),
|
||||||
@@ -49,19 +414,20 @@ def create_peer(
|
|||||||
)
|
)
|
||||||
db.add(peer)
|
db.add(peer)
|
||||||
db.commit()
|
db.commit()
|
||||||
apply_config(db)
|
apply_config(db, iface)
|
||||||
return peer
|
return peer
|
||||||
|
|
||||||
|
|
||||||
def create_peers_batch(
|
def create_peers_batch(
|
||||||
db: Session,
|
db: Session,
|
||||||
|
iface: Interface,
|
||||||
base_name: str,
|
base_name: str,
|
||||||
count: int,
|
count: int,
|
||||||
expires_at: datetime | None = None,
|
expires_at: datetime | None = None,
|
||||||
note: str = "",
|
note: str = "",
|
||||||
quota_bytes: int = 0,
|
quota_bytes: int = 0,
|
||||||
) -> list[Peer]:
|
) -> list[Peer]:
|
||||||
existing = {p.name for p in list_peers(db)}
|
existing = {p.name for p in list_peers(db, iface)}
|
||||||
peers = []
|
peers = []
|
||||||
index = 1
|
index = 1
|
||||||
for _ in range(count):
|
for _ in range(count):
|
||||||
@@ -69,7 +435,7 @@ def create_peers_batch(
|
|||||||
index += 1
|
index += 1
|
||||||
name = f"{base_name}-{index}"
|
name = f"{base_name}-{index}"
|
||||||
existing.add(name)
|
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
|
return peers
|
||||||
|
|
||||||
|
|
||||||
@@ -81,15 +447,19 @@ def update_peer(
|
|||||||
dns: str = "",
|
dns: str = "",
|
||||||
extra_allowed_ips: str = "",
|
extra_allowed_ips: str = "",
|
||||||
client_allowed_ips: str = "",
|
client_allowed_ips: str = "",
|
||||||
|
persistent_keepalive: int | None = None,
|
||||||
) -> Peer:
|
) -> Peer:
|
||||||
peer.note = note
|
peer.note = note
|
||||||
peer.quota_bytes = quota_bytes
|
peer.quota_bytes = quota_bytes
|
||||||
peer.dns = dns
|
peer.dns = dns
|
||||||
peer.extra_allowed_ips = wireguard.validate_cidr_list(extra_allowed_ips)
|
peer.extra_allowed_ips = wireguard.validate_cidr_list(extra_allowed_ips)
|
||||||
peer.client_allowed_ips = wireguard.validate_cidr_list(client_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()
|
db.commit()
|
||||||
if not peer.over_quota:
|
if not peer.over_quota:
|
||||||
apply_config(db)
|
apply_config(db, peer.interface)
|
||||||
return peer
|
return peer
|
||||||
|
|
||||||
|
|
||||||
@@ -97,7 +467,7 @@ def reset_peer_usage(db: Session, peer: Peer) -> Peer:
|
|||||||
peer.cum_rx = 0
|
peer.cum_rx = 0
|
||||||
peer.cum_tx = 0
|
peer.cum_tx = 0
|
||||||
db.commit()
|
db.commit()
|
||||||
apply_config(db)
|
apply_config(db, peer.interface)
|
||||||
return peer
|
return peer
|
||||||
|
|
||||||
|
|
||||||
@@ -109,26 +479,27 @@ def rotate_peer_keys(db: Session, peer: Peer) -> Peer:
|
|||||||
peer.private_key_enc = encrypt(private)
|
peer.private_key_enc = encrypt(private)
|
||||||
peer.preshared_key_enc = encrypt(wireguard.genpsk())
|
peer.preshared_key_enc = encrypt(wireguard.genpsk())
|
||||||
db.commit()
|
db.commit()
|
||||||
apply_config(db)
|
apply_config(db, peer.interface)
|
||||||
return peer
|
return peer
|
||||||
|
|
||||||
|
|
||||||
def toggle_peer(db: Session, peer: Peer) -> Peer:
|
def toggle_peer(db: Session, peer: Peer) -> Peer:
|
||||||
peer.enabled = not peer.enabled
|
peer.enabled = not peer.enabled
|
||||||
db.commit()
|
db.commit()
|
||||||
apply_config(db)
|
apply_config(db, peer.interface)
|
||||||
return peer
|
return peer
|
||||||
|
|
||||||
|
|
||||||
def delete_peer(db: Session, peer: Peer) -> None:
|
def delete_peer(db: Session, peer: Peer) -> None:
|
||||||
|
iface = peer.interface
|
||||||
db.delete(peer)
|
db.delete(peer)
|
||||||
db.commit()
|
db.commit()
|
||||||
apply_config(db)
|
apply_config(db, iface)
|
||||||
|
|
||||||
|
|
||||||
def disable_expired_peers(db: Session) -> bool:
|
def disable_expired_peers(db: Session) -> bool:
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
changed = False
|
changed_ifaces = []
|
||||||
for peer in list_peers(db):
|
for peer in list_peers(db):
|
||||||
if not peer.enabled:
|
if not peer.enabled:
|
||||||
continue
|
continue
|
||||||
@@ -137,47 +508,63 @@ def disable_expired_peers(db: Session) -> bool:
|
|||||||
expires = expires.replace(tzinfo=timezone.utc)
|
expires = expires.replace(tzinfo=timezone.utc)
|
||||||
if (expires is not None and expires <= now) or peer.over_quota:
|
if (expires is not None and expires <= now) or peer.over_quota:
|
||||||
peer.enabled = False
|
peer.enabled = False
|
||||||
changed = True
|
if peer.interface not in changed_ifaces:
|
||||||
if changed:
|
changed_ifaces.append(peer.interface)
|
||||||
|
if changed_ifaces:
|
||||||
db.commit()
|
db.commit()
|
||||||
apply_config(db)
|
for iface in changed_ifaces:
|
||||||
return changed
|
apply_config(db, iface)
|
||||||
|
return bool(changed_ifaces)
|
||||||
|
|
||||||
|
|
||||||
def accumulate_usage(db: Session) -> None:
|
def accumulate_usage(db: Session) -> None:
|
||||||
status = wireguard.get_status()
|
|
||||||
changed = False
|
changed = False
|
||||||
for peer in list_peers(db):
|
for iface in list_interfaces(db):
|
||||||
peer_status = status.peers.get(peer.public_key)
|
status = wireguard.get_status(iface.name)
|
||||||
if peer_status is None:
|
for peer in list_peers(db, iface):
|
||||||
continue
|
peer_status = status.peers.get(peer.public_key)
|
||||||
rx, tx = peer_status.rx_bytes, peer_status.tx_bytes
|
if peer_status is None:
|
||||||
# wg counters reset on interface restart or peer re-add
|
continue
|
||||||
delta_rx = rx - peer.last_rx if rx >= peer.last_rx else rx
|
rx, tx = peer_status.rx_bytes, peer_status.tx_bytes
|
||||||
delta_tx = tx - peer.last_tx if tx >= peer.last_tx else tx
|
# wg counters reset on interface restart or peer re-add
|
||||||
if delta_rx or delta_tx or rx != peer.last_rx or tx != peer.last_tx:
|
delta_rx = rx - peer.last_rx if rx >= peer.last_rx else rx
|
||||||
peer.cum_rx += delta_rx
|
delta_tx = tx - peer.last_tx if tx >= peer.last_tx else tx
|
||||||
peer.cum_tx += delta_tx
|
if delta_rx or delta_tx or rx != peer.last_rx or tx != peer.last_tx:
|
||||||
peer.last_rx = rx
|
peer.cum_rx += delta_rx
|
||||||
peer.last_tx = tx
|
peer.cum_tx += delta_tx
|
||||||
changed = True
|
peer.last_rx = rx
|
||||||
|
peer.last_tx = tx
|
||||||
|
changed = True
|
||||||
if changed:
|
if changed:
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
def apply_config(db: Session) -> None:
|
def apply_config(db: Session, iface: Interface) -> None:
|
||||||
private, _ = wireguard.ensure_server_keys()
|
if not iface.enabled:
|
||||||
wireguard.sync_peers(private, list_peers(db))
|
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:
|
def sample_traffic(db: Session) -> None:
|
||||||
status = wireguard.get_status()
|
for iface in list_interfaces(db):
|
||||||
for peer_status in status.peers.values():
|
status = wireguard.get_status(iface.name)
|
||||||
db.add(
|
for peer_status in status.peers.values():
|
||||||
TrafficSample(
|
db.add(
|
||||||
peer_public_key=peer_status.public_key,
|
TrafficSample(
|
||||||
rx_bytes=peer_status.rx_bytes,
|
interface_name=iface.name,
|
||||||
tx_bytes=peer_status.tx_bytes,
|
peer_public_key=peer_status.public_key,
|
||||||
|
rx_bytes=peer_status.rx_bytes,
|
||||||
|
tx_bytes=peer_status.tx_bytes,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
db.commit()
|
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) {
|
function fmtBytes(num) {
|
||||||
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
|
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
|
||||||
@@ -28,39 +28,45 @@ async function refresh() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const online = data.peers.filter((p) => p.online).length;
|
if (data.meta && data.meta.refresh_seconds) {
|
||||||
const set = (id, value) => {
|
refreshMs = data.meta.refresh_seconds * 1000;
|
||||||
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));
|
|
||||||
|
|
||||||
const tbody = document.querySelector("#peer-table tbody");
|
const tbody = document.querySelector("#peer-table tbody");
|
||||||
if (!tbody) return;
|
if (!tbody) return;
|
||||||
const badgeBase = "rounded-full px-2 py-0.5 text-xs font-medium";
|
const badgeBase = "rounded-full px-2 py-0.5 text-xs font-medium";
|
||||||
tbody.innerHTML = "";
|
const esc = (s) =>
|
||||||
for (const p of data.peers) {
|
String(s).replace(/[&<>"]/g, (ch) =>
|
||||||
const tr = document.createElement("tr");
|
({ "&": "&", "<": "<", ">": ">", '"': """ })[ch]
|
||||||
tr.className = "hover:bg-slate-50";
|
);
|
||||||
const badge = p.online
|
let html = "";
|
||||||
? `<span class="${badgeBase} bg-emerald-100 text-emerald-700">online</span>`
|
for (const iface of data.interfaces) {
|
||||||
: p.enabled
|
for (const p of iface.peers) {
|
||||||
? `<span class="${badgeBase} bg-slate-100 text-slate-500">offline</span>`
|
const badge = p.online
|
||||||
: `<span class="${badgeBase} bg-red-100 text-red-700">disabled</span>`;
|
? `<span class="${badgeBase} bg-emerald-100 text-emerald-700">online</span>`
|
||||||
tr.innerHTML =
|
: p.enabled
|
||||||
`<td class="px-4 py-3"><a href="/peers/${p.id}" class="font-medium text-blue-600 hover:underline"></a></td>` +
|
? `<span class="${badgeBase} bg-slate-100 text-slate-500">offline</span>`
|
||||||
`<td class="px-4 py-3 font-mono text-xs">${p.address}</td>` +
|
: `<span class="${badgeBase} bg-red-100 text-red-700">disabled</span>`;
|
||||||
`<td class="px-4 py-3">${badge}</td>` +
|
html +=
|
||||||
`<td class="px-4 py-3 text-slate-500">${fmtHandshake(p.latest_handshake)}</td>` +
|
`<tr class="hover:bg-slate-50">` +
|
||||||
`<td class="px-4 py-3 text-slate-500">${fmtBytes(p.rx_bytes)}</td>` +
|
`<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="px-4 py-3 text-slate-500">${fmtBytes(p.tx_bytes)}</td>`;
|
`<td class="truncate px-4 py-3 text-slate-500">${esc(iface.name)}</td>` +
|
||||||
tr.querySelector("a").textContent = p.name;
|
`<td class="truncate px-4 py-3 font-mono text-xs">${esc(p.address)}</td>` +
|
||||||
tbody.appendChild(tr);
|
`<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();
|
async function loop() {
|
||||||
setInterval(refresh, REFRESH_MS);
|
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 charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{% block title %}WireGuard Admin{% endblock %}</title>
|
<title>{% block title %}WireGuard Admin{% endblock %}</title>
|
||||||
<script src="/static/tailwind.js"></script>
|
<link rel="stylesheet" href="/static/tailwind.css">
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen bg-slate-100 text-slate-800">
|
<body class="min-h-screen bg-slate-100 text-slate-800">
|
||||||
<nav class="bg-slate-900 text-slate-300">
|
<nav class="bg-slate-900 text-slate-300">
|
||||||
<div class="mx-auto flex max-w-5xl items-center gap-6 px-6 py-3">
|
<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>
|
<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="/" 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="/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>
|
<a href="/settings" class="text-sm hover:text-white">Settings</a>
|
||||||
<form method="post" action="/logout" class="ml-auto">
|
<form method="post" action="/logout" class="ml-auto">
|
||||||
<button type="submit" class="text-sm text-slate-400 hover:text-white">Logout</button>
|
<button type="submit" class="text-sm text-slate-400 hover:text-white">Logout</button>
|
||||||
|
|||||||
@@ -4,44 +4,50 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<h1 class="mb-6 text-2xl font-bold">Dashboard</h1>
|
<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">
|
<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">
|
<p class="flex items-center gap-2 text-lg font-semibold">
|
||||||
{{ status.name }}
|
<a href="/peers?interface={{ item.iface.id }}" class="text-blue-600 hover:underline">{{ item.iface.name }}</a>
|
||||||
{% if status.up %}
|
{% 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>
|
<span class="rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700">up</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700">down</span>
|
<span class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700">down</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-1 text-sm text-slate-500">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-xl bg-white p-5 shadow-sm">
|
{% else %}
|
||||||
<h3 class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-400">Peers</h3>
|
<p class="rounded-xl bg-white p-6 text-center text-slate-400 shadow-sm sm:col-span-2 lg:col-span-3">
|
||||||
<p class="text-lg font-semibold"><span id="peer-count">{{ peers | length }}</span> total</p>
|
No interfaces. <a href="/interfaces" class="text-blue-600 hover:underline">Create or import one.</a>
|
||||||
<p class="mt-1 text-sm text-slate-500"><span id="online-count">-</span> online</p>
|
</p>
|
||||||
</div>
|
{% endfor %}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 class="mb-3 text-lg font-semibold">Peers</h2>
|
<h2 class="mb-3 text-lg font-semibold">Peers</h2>
|
||||||
<div class="overflow-hidden rounded-xl bg-white shadow-sm">
|
<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>
|
<thead>
|
||||||
<tr class="bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-400">
|
<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="w-[18%] px-4 py-3 font-semibold">Name</th>
|
||||||
<th class="px-4 py-3 font-semibold">Address</th>
|
<th class="w-[12%] px-4 py-3 font-semibold">Interface</th>
|
||||||
<th class="px-4 py-3 font-semibold">Status</th>
|
<th class="w-[18%] px-4 py-3 font-semibold">Address</th>
|
||||||
<th class="px-4 py-3 font-semibold">Handshake</th>
|
<th class="w-[12%] px-4 py-3 font-semibold">Status</th>
|
||||||
<th class="px-4 py-3 font-semibold">RX</th>
|
<th class="w-[14%] px-4 py-3 font-semibold">Handshake</th>
|
||||||
<th class="px-4 py-3 font-semibold">TX</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-slate-100"></tbody>
|
<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 charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Login - WireGuard Admin</title>
|
<title>Login - WireGuard Admin</title>
|
||||||
<script src="/static/tailwind.js"></script>
|
<link rel="stylesheet" href="/static/tailwind.css">
|
||||||
</head>
|
</head>
|
||||||
<body class="flex min-h-screen items-center justify-center bg-slate-900">
|
<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">
|
<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>
|
<p class="mb-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">{{ error }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="mb-6 flex items-center justify-between">
|
<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">
|
<div class="flex gap-2">
|
||||||
<form method="post" action="/peers/{{ peer.id }}/toggle">
|
<form method="post" action="/peers/{{ peer.id }}/toggle">
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
@@ -96,6 +102,13 @@
|
|||||||
value="{{ '%.1f' % (peer.quota_bytes / 1073741824) if peer.quota_bytes else '' }}"
|
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">
|
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>
|
||||||
|
<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
|
<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"
|
<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">
|
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">
|
<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>
|
<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">
|
<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">
|
<div class="mt-4 flex justify-center gap-2">
|
||||||
<a href="/peers/{{ peer.id }}/config"
|
<a href="/peers/{{ peer.id }}/config"
|
||||||
@@ -158,14 +172,34 @@
|
|||||||
Copy
|
Copy
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if client_config %}
|
||||||
<div class="mt-4 rounded-xl bg-white p-5 shadow-sm">
|
<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>
|
<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>
|
<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>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if client_config %}
|
||||||
<div class="mt-4 rounded-xl bg-white p-5 shadow-sm">
|
<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>
|
<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">
|
<div class="space-y-5 text-sm">
|
||||||
@@ -205,6 +239,7 @@ nmcli connection up {{ peer.name }}</pre>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function copyConfig(btn) {
|
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>
|
<p class="mb-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">{{ error }}</p>
|
||||||
{% endif %}
|
{% 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">
|
<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">
|
<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
|
<label class="block text-sm font-medium text-slate-600">Name
|
||||||
<input name="name" required placeholder="laptop"
|
<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">
|
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">
|
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>
|
||||||
<label class="block text-sm font-medium text-slate-600">DNS
|
<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">
|
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>
|
||||||
<label class="block text-sm font-medium text-slate-600">Quota (GiB)
|
<label class="block text-sm font-medium text-slate-600">Quota (GiB)
|
||||||
@@ -47,9 +64,9 @@
|
|||||||
<input name="client_allowed_ips" placeholder="server default"
|
<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">
|
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>
|
||||||
<div class="flex items-end">
|
<div class="col-span-full">
|
||||||
<button type="submit"
|
<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)
|
Add peer(s)
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -78,6 +95,9 @@
|
|||||||
<tr class="hover:bg-slate-50">
|
<tr class="hover:bg-slate-50">
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<a href="/peers/{{ peer.id }}" class="font-medium text-blue-600 hover:underline">{{ peer.name }}</a>
|
<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>
|
||||||
<td class="px-4 py-3 font-mono text-xs">{{ peer.address }}</td>
|
<td class="px-4 py-3 font-mono text-xs">{{ peer.address }}</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
@@ -124,4 +144,5 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+122
-26
@@ -3,32 +3,125 @@
|
|||||||
{% block breadcrumbs %}<span>/</span> <span>Settings</span>{% endblock %}
|
{% block breadcrumbs %}<span>/</span> <span>Settings</span>{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1 class="mb-6 text-2xl font-bold">Settings</h1>
|
<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">
|
<dl class="divide-y divide-slate-100 text-sm">
|
||||||
|
{% for iface in interfaces %}
|
||||||
<div class="flex justify-between gap-4 py-3">
|
<div class="flex justify-between gap-4 py-3">
|
||||||
<dt class="text-slate-500">Interface</dt>
|
<dt class="text-slate-500">{{ iface.name }}</dt>
|
||||||
<dd class="font-medium">{{ settings.wg_interface }}</dd>
|
<dd class="text-right">
|
||||||
</div>
|
<span class="font-mono text-xs">{{ server_address(iface.subnet) }}</span>
|
||||||
<div class="flex justify-between gap-4 py-3">
|
· {{ iface.host }}:{{ iface.listen_port }}
|
||||||
<dt class="text-slate-500">Server address</dt>
|
<span class="ml-2 block break-all font-mono text-xs text-slate-400 sm:inline">{{ iface.public_key }}</span>
|
||||||
<dd class="font-mono text-xs">{{ server_address }}</dd>
|
</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>
|
|
||||||
</div>
|
</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">
|
<div class="flex justify-between gap-4 py-3">
|
||||||
<dt class="text-slate-500">Client DNS</dt>
|
<dt class="text-slate-500">Client DNS</dt>
|
||||||
<dd class="font-medium">{{ settings.wg_dns }}</dd>
|
<dd class="font-medium">{{ settings.wg_dns }}</dd>
|
||||||
@@ -42,10 +135,13 @@
|
|||||||
<dd class="font-medium">{{ settings.wg_persistent_keepalive }}s</dd>
|
<dd class="font-medium">{{ settings.wg_persistent_keepalive }}s</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between gap-4 py-3">
|
<div class="flex justify-between gap-4 py-3">
|
||||||
<dt class="text-slate-500">Peer isolation</dt>
|
<dt class="text-slate-500">Config directory</dt>
|
||||||
<dd class="font-medium">{{ "on" if settings.wg_peer_isolation else "off" }}</dd>
|
<dd class="font-mono text-xs">{{ settings.wg_config_dir }}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</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>
|
</div>
|
||||||
{% endblock %}
|
{% 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}"]
|
||||||
+7
-11
@@ -5,17 +5,13 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
cap_add:
|
cap_add:
|
||||||
- NET_ADMIN
|
- NET_ADMIN
|
||||||
sysctls:
|
# Host networking: new interfaces and their UDP ports work without
|
||||||
- net.ipv4.ip_forward=1
|
# editing port mappings. The admin UI listens on host port 8000.
|
||||||
- net.ipv4.conf.all.src_valid_mark=1
|
# Requires net.ipv4.ip_forward=1 on the host (sysctls cannot be set
|
||||||
|
# per-container in host network mode); entrypoint.sh enables it.
|
||||||
|
network_mode: host
|
||||||
devices:
|
devices:
|
||||||
- /dev/net/tun:/dev/net/tun
|
- /dev/net/tun:/dev/net/tun
|
||||||
# For relay/组网 into host tunnels (WG_RELAY_SUBNETS), use host networking:
|
|
||||||
# uncomment network_mode and remove the ports section.
|
|
||||||
# network_mode: host
|
|
||||||
ports:
|
|
||||||
- "${WG_PORT:-51821}:${WG_PORT:-51821}/udp"
|
|
||||||
- "8000:8000/tcp"
|
|
||||||
environment:
|
environment:
|
||||||
WG_INTERFACE: ${WG_INTERFACE:-wg1}
|
WG_INTERFACE: ${WG_INTERFACE:-wg1}
|
||||||
WG_HOST: ${WG_HOST:?set WG_HOST to your public IP or domain}
|
WG_HOST: ${WG_HOST:?set WG_HOST to your public IP or domain}
|
||||||
@@ -29,5 +25,5 @@ services:
|
|||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env}
|
||||||
SECRET_KEY: ${SECRET_KEY:?set SECRET_KEY in .env}
|
SECRET_KEY: ${SECRET_KEY:?set SECRET_KEY in .env}
|
||||||
volumes:
|
volumes:
|
||||||
- ./wg-admin-data:/opt/wireguard-admin/data
|
- ./volumes/admin-data:/opt/wireguard-admin/data
|
||||||
- ./wg-config:/etc/wireguard
|
- ./volumes/config:/etc/wireguard
|
||||||
|
|||||||
+8
-17
@@ -1,26 +1,17 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
WG_INTERFACE="${WG_INTERFACE:-wg0}"
|
# Host network mode shares the host network namespace, so forwarding is
|
||||||
WG_SUBNET="${WG_SUBNET:-10.8.0.0/24}"
|
# enabled here instead of via per-container sysctls.
|
||||||
OUT_IFACE="$(ip route show default | awk '/default/ {print $5; exit}')"
|
sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1 || \
|
||||||
|
echo "WARN: could not enable net.ipv4.ip_forward, set it on the host"
|
||||||
|
sysctl -w net.ipv4.conf.all.src_valid_mark=1 >/dev/null 2>&1 || true
|
||||||
|
|
||||||
iptables -t nat -C POSTROUTING -s "$WG_SUBNET" -o "$OUT_IFACE" -j MASQUERADE 2>/dev/null \
|
# NAT and peer isolation rules are managed per interface by the app
|
||||||
|| iptables -t nat -A POSTROUTING -s "$WG_SUBNET" -o "$OUT_IFACE" -j MASQUERADE
|
# (app/wireguard/sync.py) when configs are applied.
|
||||||
# Peer isolation: block peer-to-peer traffic inside the wg subnet.
|
|
||||||
if [ "${WG_PEER_ISOLATION:-false}" = "true" ]; then
|
|
||||||
iptables -C FORWARD -i "$WG_INTERFACE" -o "$WG_INTERFACE" -j DROP 2>/dev/null \
|
|
||||||
|| iptables -I FORWARD 1 -i "$WG_INTERFACE" -o "$WG_INTERFACE" -j DROP
|
|
||||||
else
|
|
||||||
iptables -D FORWARD -i "$WG_INTERFACE" -o "$WG_INTERFACE" -j DROP 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
iptables -C FORWARD -i "$WG_INTERFACE" -j ACCEPT 2>/dev/null \
|
|
||||||
|| iptables -A FORWARD -i "$WG_INTERFACE" -j ACCEPT
|
|
||||||
iptables -C FORWARD -o "$WG_INTERFACE" -j ACCEPT 2>/dev/null \
|
|
||||||
|| iptables -A FORWARD -o "$WG_INTERFACE" -j ACCEPT
|
|
||||||
|
|
||||||
# Relay wg clients into extra networks (e.g. another wg tunnel on the host).
|
# Relay wg clients into extra networks (e.g. another wg tunnel on the host).
|
||||||
|
WG_SUBNET="${WG_SUBNET:-10.8.0.0/24}"
|
||||||
OLD_IFS="$IFS"; IFS=','
|
OLD_IFS="$IFS"; IFS=','
|
||||||
for subnet in ${WG_RELAY_SUBNETS:-}; do
|
for subnet in ${WG_RELAY_SUBNETS:-}; do
|
||||||
subnet="$(echo "$subnet" | tr -d ' ')"
|
subnet="$(echo "$subnet" | tr -d ' ')"
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module.exports = {
|
||||||
|
content: ["./app/templates/**/*.html", "./app/static/*.js"],
|
||||||
|
theme: { extend: {} },
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user