Multi-interface management, topology view, performance tuning, precompiled CSS

- Interface model with per-interface subnet/port/keys; import/adopt existing wg-quick configs (key-less imported peers, optional key rotation), cascade delete
- Split wireguard.py into a package (keys via cryptography X25519, status, addressing, conf parse/render, sync, host tuning)
- ECharts horizontal topology view (interface -> peers -> site subnets)
- Advanced options: MTU, MSS clamping, FwMark/Table, custom PostUp/PostDown, per-peer keepalive override
- Runtime settings (sample interval/retention, online threshold, UI refresh) with traffic sample pruning; host tuning (UDP buffers, backlog, GRO forwarding)
- Precompiled Tailwind CSS replacing Play CDN runtime (fixes FOUC); stable table layout and diffed polling renders
- Host network mode in compose; NAT/isolation iptables moved into app sync

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-07-05 14:01:44 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent 192960ad3e
commit 1b227c2470
31 changed files with 2361 additions and 583 deletions
+1
View File
@@ -4,4 +4,5 @@ __pycache__/
data/
wg-admin-data/
wg-config/
volumes/
.env
+1 -1
View File
@@ -1,7 +1,7 @@
FROM python:3.13-slim
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/*
WORKDIR /opt/wireguard-admin
+90 -4
View File
@@ -35,13 +35,99 @@ PEER_MIGRATIONS = {
"last_tx": "ALTER TABLE peers ADD COLUMN last_tx INTEGER NOT NULL DEFAULT 0",
}
PEER_COLUMNS = (
"id, interface_id, name, public_key, private_key_enc, preshared_key_enc, "
"address, enabled, expires_at, created_at, note, dns, extra_allowed_ips, "
"client_allowed_ips, quota_bytes, cum_rx, cum_tx, last_rx, last_tx"
)
def _columns(conn, table: str) -> set[str]:
return {row[1] for row in conn.execute(text(f"PRAGMA table_info({table})"))}
def _rebuild_peers_with_interface(conn) -> None:
# SQLite cannot alter unique constraints in place; rebuild the table so
# name/address become unique per interface instead of globally.
conn.execute(text("ALTER TABLE peers RENAME TO peers_old"))
conn.execute(
text(
"""
CREATE TABLE peers (
id INTEGER NOT NULL PRIMARY KEY,
interface_id INTEGER NOT NULL DEFAULT 1 REFERENCES interfaces (id) ON DELETE CASCADE,
name VARCHAR(64) NOT NULL,
public_key VARCHAR(64) NOT NULL UNIQUE,
private_key_enc VARCHAR(256) NOT NULL DEFAULT '',
preshared_key_enc VARCHAR(256) NOT NULL DEFAULT '',
address VARCHAR(64) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT 1,
expires_at DATETIME,
created_at DATETIME NOT NULL,
note VARCHAR(256) NOT NULL DEFAULT '',
dns VARCHAR(128) NOT NULL DEFAULT '',
extra_allowed_ips VARCHAR(512) NOT NULL DEFAULT '',
client_allowed_ips VARCHAR(512) NOT NULL DEFAULT '',
quota_bytes INTEGER NOT NULL DEFAULT 0,
cum_rx INTEGER NOT NULL DEFAULT 0,
cum_tx INTEGER NOT NULL DEFAULT 0,
last_rx INTEGER NOT NULL DEFAULT 0,
last_tx INTEGER NOT NULL DEFAULT 0,
CONSTRAINT uq_peer_iface_name UNIQUE (interface_id, name),
CONSTRAINT uq_peer_iface_address UNIQUE (interface_id, address)
)
"""
)
)
old_columns = _columns(conn, "peers_old")
select_columns = ", ".join(
column.strip() if column.strip() in old_columns else f"1 AS {column.strip()}"
for column in PEER_COLUMNS.split(",")
)
conn.execute(
text(
f"INSERT INTO peers ({PEER_COLUMNS}) SELECT {select_columns} FROM peers_old"
)
)
conn.execute(text("DROP TABLE peers_old"))
conn.execute(
text("CREATE INDEX ix_peers_interface_id ON peers (interface_id)")
)
INTERFACE_MIGRATIONS = {
"mtu": "ALTER TABLE interfaces ADD COLUMN mtu INTEGER NOT NULL DEFAULT 0",
"mss_clamp": "ALTER TABLE interfaces ADD COLUMN mss_clamp BOOLEAN NOT NULL DEFAULT 0",
"fwmark": "ALTER TABLE interfaces ADD COLUMN fwmark VARCHAR(32) NOT NULL DEFAULT ''",
"route_table": "ALTER TABLE interfaces ADD COLUMN route_table VARCHAR(32) NOT NULL DEFAULT ''",
"post_up": "ALTER TABLE interfaces ADD COLUMN post_up VARCHAR(2048) NOT NULL DEFAULT ''",
"post_down": "ALTER TABLE interfaces ADD COLUMN post_down VARCHAR(2048) NOT NULL DEFAULT ''",
}
def run_migrations() -> None:
with engine.connect() as conn:
existing = {
row[1] for row in conn.execute(text("PRAGMA table_info(peers)"))
}
peer_columns = _columns(conn, "peers")
for column, ddl in PEER_MIGRATIONS.items():
if existing and column not in existing:
if peer_columns and column not in peer_columns:
conn.execute(text(ddl))
if peer_columns and "interface_id" not in peer_columns:
_rebuild_peers_with_interface(conn)
peer_columns = _columns(conn, "peers")
if peer_columns and "persistent_keepalive" not in peer_columns:
conn.execute(
text("ALTER TABLE peers ADD COLUMN persistent_keepalive INTEGER")
)
iface_columns = _columns(conn, "interfaces")
for column, ddl in INTERFACE_MIGRATIONS.items():
if iface_columns and column not in iface_columns:
conn.execute(text(ddl))
sample_columns = _columns(conn, "traffic_samples")
if sample_columns and "interface_name" not in sample_columns:
conn.execute(
text(
"ALTER TABLE traffic_samples ADD COLUMN "
"interface_name VARCHAR(15) NOT NULL DEFAULT ''"
)
)
conn.commit()
+309 -66
View File
@@ -16,19 +16,30 @@ from .auth import SESSION_COOKIE, create_session_token, logged_in, verify_creden
from .config import settings
from .db import Base, SessionLocal, engine, get_db, run_migrations
TRAFFIC_SAMPLE_INTERVAL = 60
PRUNE_EVERY_TICKS = 60
templates = Jinja2Templates(directory="app/templates")
async def _background_sampler() -> None:
tick = 0
while True:
await asyncio.sleep(TRAFFIC_SAMPLE_INTERVAL)
db = SessionLocal()
try:
interval = service.get_runtime_settings(db)["traffic_sample_interval"]
except Exception:
interval = 60
finally:
db.close()
await asyncio.sleep(interval)
db = SessionLocal()
try:
service.accumulate_usage(db)
service.disable_expired_peers(db)
service.sample_traffic(db)
tick += 1
if tick % PRUNE_EVERY_TICKS == 0:
service.prune_traffic_samples(db)
except Exception:
pass
finally:
@@ -41,11 +52,12 @@ async def lifespan(app: FastAPI):
run_migrations()
db = SessionLocal()
try:
service.apply_config(db)
try:
wireguard.interface_up()
except Exception:
pass
service.bootstrap_default_interface(db)
wireguard.status_module.online_threshold_seconds = (
service.get_runtime_settings(db)["online_threshold"]
)
service.prune_traffic_samples(db)
service.apply_all_configs(db)
finally:
db.close()
task = asyncio.create_task(_background_sampler())
@@ -66,6 +78,7 @@ def _fmt_bytes(num: float) -> str:
templates.env.filters["fmt_bytes"] = _fmt_bytes
templates.env.globals["server_address"] = wireguard.server_address
@app.get("/login", response_class=HTMLResponse)
@@ -97,30 +110,164 @@ def logout():
return response
def _interface_overview(db: Session) -> list[dict]:
overview = []
for iface in service.list_interfaces(db):
status = wireguard.get_status(iface.name)
peers = service.list_peers(db, iface)
overview.append(
{
"iface": iface,
"status": status,
"peers": peers,
"online": sum(
1
for p in peers
if (ps := status.peers.get(p.public_key)) is not None and ps.online
),
}
)
return overview
@app.get("/", response_class=HTMLResponse, dependencies=[logged_in])
def dashboard(request: Request, db: Session = Depends(get_db)):
status = wireguard.get_status()
peers = service.list_peers(db)
return templates.TemplateResponse(
request, "dashboard.html", {"overview": _interface_overview(db)}
)
@app.get("/interfaces", response_class=HTMLResponse, dependencies=[logged_in])
def interfaces_page(request: Request, db: Session = Depends(get_db), error: str = ""):
return templates.TemplateResponse(
request,
"dashboard.html",
"interfaces.html",
{
"status": status,
"peers": peers,
"overview": _interface_overview(db),
"candidates": service.import_candidates(db),
"error": error,
"settings": settings,
"server_address": wireguard.server_address(),
},
)
def _interfaces_error(exc: Exception) -> RedirectResponse:
return RedirectResponse(f"/interfaces?error={quote(str(exc))}", status_code=303)
@app.post("/interfaces", dependencies=[logged_in])
def create_interface(
db: Session = Depends(get_db),
name: str = Form(...),
subnet: str = Form(...),
listen_port: int = Form(...),
host: str = Form(...),
dns: str = Form(""),
allowed_ips: str = Form(""),
persistent_keepalive: int = Form(25),
peer_isolation: bool = Form(False),
):
try:
service.create_interface(
db, name, subnet, listen_port, host, dns,
allowed_ips, persistent_keepalive, peer_isolation,
)
except (ValueError, RuntimeError) as exc:
return _interfaces_error(exc)
return RedirectResponse("/interfaces", status_code=303)
def _get_interface_or_404(db: Session, interface_id: int):
iface = service.get_interface(db, interface_id)
if iface is None:
raise HTTPException(status_code=404, detail="Interface not found")
return iface
@app.post("/interfaces/import", dependencies=[logged_in])
def import_interface(
db: Session = Depends(get_db), name: str = Form(...), host: str = Form(...)
):
try:
service.import_interface(db, name, host)
except (ValueError, RuntimeError) as exc:
return _interfaces_error(exc)
return RedirectResponse("/interfaces", status_code=303)
@app.post("/interfaces/{interface_id}/update", dependencies=[logged_in])
def update_interface(
interface_id: int,
db: Session = Depends(get_db),
host: str = Form(...),
dns: str = Form(""),
allowed_ips: str = Form(""),
persistent_keepalive: int = Form(25),
peer_isolation: bool = Form(False),
mtu: int = Form(0),
mss_clamp: bool = Form(False),
fwmark: str = Form(""),
route_table: str = Form(""),
post_up: str = Form(""),
post_down: str = Form(""),
):
iface = _get_interface_or_404(db, interface_id)
try:
service.update_interface(
db, iface, host, dns, allowed_ips, persistent_keepalive, peer_isolation,
mtu, mss_clamp, fwmark, route_table, post_up, post_down,
)
except (ValueError, RuntimeError) as exc:
return _interfaces_error(exc)
return RedirectResponse("/interfaces", status_code=303)
@app.post("/interfaces/{interface_id}/toggle", dependencies=[logged_in])
def toggle_interface(interface_id: int, db: Session = Depends(get_db)):
try:
service.toggle_interface(db, _get_interface_or_404(db, interface_id))
except (ValueError, RuntimeError) as exc:
return _interfaces_error(exc)
return RedirectResponse("/interfaces", status_code=303)
@app.post("/interfaces/{interface_id}/delete", dependencies=[logged_in])
def delete_interface(
interface_id: int, db: Session = Depends(get_db), cascade: bool = Form(False)
):
iface = _get_interface_or_404(db, interface_id)
try:
service.delete_interface(db, iface, cascade)
except (ValueError, RuntimeError) as exc:
return _interfaces_error(exc)
return RedirectResponse("/interfaces", status_code=303)
@app.get("/peers", response_class=HTMLResponse, dependencies=[logged_in])
def peers_page(request: Request, db: Session = Depends(get_db), error: str = ""):
status = wireguard.get_status()
peers = service.list_peers(db)
def peers_page(
request: Request,
db: Session = Depends(get_db),
interface: int = 0,
error: str = "",
):
interfaces = service.list_interfaces(db)
current = None
if interface:
current = next((i for i in interfaces if i.id == interface), None)
if current is None and interfaces:
current = interfaces[0]
peers = service.list_peers(db, current) if current else []
status = wireguard.get_status(current.name) if current else wireguard.InterfaceStatus(name="")
return templates.TemplateResponse(
request,
"peers.html",
{"peers": peers, "status": status, "error": error, "settings": settings},
{
"peers": peers,
"status": status,
"error": error,
"interfaces": interfaces,
"current": current,
},
)
@@ -132,6 +279,7 @@ def _parse_quota_gib(value: str) -> int:
@app.post("/peers", dependencies=[logged_in])
def create_peer(
db: Session = Depends(get_db),
interface_id: int = Form(...),
name: str = Form(...),
expires_at: str = Form(""),
note: str = Form(""),
@@ -142,16 +290,18 @@ def create_peer(
extra_allowed_ips: str = Form(""),
client_allowed_ips: str = Form(""),
):
iface = _get_interface_or_404(db, interface_id)
expiry = datetime.fromisoformat(expires_at) if expires_at else None
quota = _parse_quota_gib(quota_gib)
try:
if count > 1:
service.create_peers_batch(
db, name.strip(), min(count, 50), expiry, note.strip(), quota
db, iface, name.strip(), min(count, 50), expiry, note.strip(), quota
)
return RedirectResponse("/peers", status_code=303)
return RedirectResponse(f"/peers?interface={iface.id}", status_code=303)
peer = service.create_peer(
db,
iface,
name.strip(),
expiry,
note.strip(),
@@ -162,7 +312,9 @@ def create_peer(
client_allowed_ips.strip(),
)
except ValueError as exc:
return RedirectResponse(f"/peers?error={quote(str(exc))}", status_code=303)
return RedirectResponse(
f"/peers?interface={iface.id}&error={quote(str(exc))}", status_code=303
)
return RedirectResponse(f"/peers/{peer.id}", status_code=303)
@@ -178,18 +330,21 @@ def peer_detail(
request: Request, peer_id: int, db: Session = Depends(get_db), error: str = ""
):
peer = _get_peer_or_404(db, peer_id)
status = wireguard.get_status()
_, server_public = wireguard.ensure_server_keys()
client_config = wireguard.render_client_config(peer, server_public)
iface = peer.interface
status = wireguard.get_status(iface.name)
client_config = (
wireguard.render_client_config(peer, iface) if peer.has_private_key else None
)
return templates.TemplateResponse(
request,
"peer_detail.html",
{
"peer": peer,
"iface": iface,
"peer_status": status.peers.get(peer.public_key),
"client_config": client_config,
"error": error,
"server_tunnel_ip": wireguard.server_address().split("/")[0],
"server_tunnel_ip": wireguard.server_address(iface.subnet).split("/")[0],
},
)
@@ -203,9 +358,11 @@ def update_peer(
dns: str = Form(""),
extra_allowed_ips: str = Form(""),
client_allowed_ips: str = Form(""),
persistent_keepalive: str = Form(""),
):
peer = _get_peer_or_404(db, peer_id)
try:
keepalive = int(persistent_keepalive) if persistent_keepalive.strip() else None
service.update_peer(
db,
peer,
@@ -214,6 +371,7 @@ def update_peer(
dns.strip(),
extra_allowed_ips.strip(),
client_allowed_ips.strip(),
keepalive,
)
except ValueError as exc:
return RedirectResponse(
@@ -230,7 +388,8 @@ def reset_usage(peer_id: int, db: Session = Depends(get_db)):
@app.post("/peers/{peer_id}/toggle", dependencies=[logged_in])
def toggle_peer(peer_id: int, db: Session = Depends(get_db)):
service.toggle_peer(db, _get_peer_or_404(db, peer_id))
peer = _get_peer_or_404(db, peer_id)
service.toggle_peer(db, peer)
return RedirectResponse(f"/peers/{peer_id}", status_code=303)
@@ -242,15 +401,21 @@ def rotate_peer(peer_id: int, db: Session = Depends(get_db)):
@app.post("/peers/{peer_id}/delete", dependencies=[logged_in])
def delete_peer(peer_id: int, db: Session = Depends(get_db)):
service.delete_peer(db, _get_peer_or_404(db, peer_id))
return RedirectResponse("/peers", status_code=303)
peer = _get_peer_or_404(db, peer_id)
interface_id = peer.interface_id
service.delete_peer(db, peer)
return RedirectResponse(f"/peers?interface={interface_id}", status_code=303)
@app.get("/peers/{peer_id}/config", dependencies=[logged_in])
def peer_config(peer_id: int, db: Session = Depends(get_db)):
peer = _get_peer_or_404(db, peer_id)
_, server_public = wireguard.ensure_server_keys()
config = wireguard.render_client_config(peer, server_public)
if not peer.has_private_key:
raise HTTPException(
status_code=409,
detail="Imported peer has no private key. Rotate keys first.",
)
config = wireguard.render_client_config(peer, peer.interface)
return Response(
config,
media_type="text/plain",
@@ -261,8 +426,12 @@ def peer_config(peer_id: int, db: Session = Depends(get_db)):
@app.get("/peers/{peer_id}/qr", dependencies=[logged_in])
def peer_qr(peer_id: int, db: Session = Depends(get_db)):
peer = _get_peer_or_404(db, peer_id)
_, server_public = wireguard.ensure_server_keys()
config = wireguard.render_client_config(peer, server_public)
if not peer.has_private_key:
raise HTTPException(
status_code=409,
detail="Imported peer has no private key. Rotate keys first.",
)
config = wireguard.render_client_config(peer, peer.interface)
image = qrcode.make(config)
buffer = io.BytesIO()
image.save(buffer, format="PNG")
@@ -270,53 +439,127 @@ def peer_qr(peer_id: int, db: Session = Depends(get_db)):
return StreamingResponse(buffer, media_type="image/png")
@app.get("/topology", response_class=HTMLResponse, dependencies=[logged_in])
def topology_page(request: Request):
return templates.TemplateResponse(request, "topology.html", {})
@app.get("/settings", response_class=HTMLResponse, dependencies=[logged_in])
def settings_page(request: Request):
_, server_public = wireguard.ensure_server_keys()
def settings_page(
request: Request, db: Session = Depends(get_db), error: str = "", message: str = ""
):
return templates.TemplateResponse(
request,
"settings.html",
{
"settings": settings,
"server_public": server_public,
"server_address": wireguard.server_address(),
"interfaces": service.list_interfaces(db),
"runtime": service.get_runtime_settings(db),
"tuning": wireguard.tuning.read_host_tuning(),
"error": error,
"message": message,
},
)
@app.post("/settings/runtime", dependencies=[logged_in])
def update_runtime(
db: Session = Depends(get_db),
traffic_sample_interval: int = Form(...),
traffic_retention_days: int = Form(...),
online_threshold: int = Form(...),
ui_refresh_seconds: int = Form(...),
):
try:
service.update_runtime_settings(
db,
{
"traffic_sample_interval": traffic_sample_interval,
"traffic_retention_days": traffic_retention_days,
"online_threshold": online_threshold,
"ui_refresh_seconds": ui_refresh_seconds,
},
)
except ValueError as exc:
return RedirectResponse(f"/settings?error={quote(str(exc))}", status_code=303)
return RedirectResponse("/settings?message=Saved", status_code=303)
@app.post("/settings/tuning", dependencies=[logged_in])
def apply_host_tuning(
udp_buffer_mib: int = Form(0),
netdev_backlog: int = Form(0),
gro_forwarding: str = Form(""),
):
errors: list[str] = []
if udp_buffer_mib:
if not 1 <= udp_buffer_mib <= 64:
errors.append("UDP buffer must be 1-64 MiB")
else:
errors += wireguard.tuning.apply_udp_buffers(udp_buffer_mib * 1024 * 1024)
if netdev_backlog:
if not 1000 <= netdev_backlog <= 100000:
errors.append("Backlog must be 1000-100000")
else:
errors += wireguard.tuning.apply_backlog(netdev_backlog)
if gro_forwarding in ("on", "off"):
errors += wireguard.tuning.apply_gro_forwarding(gro_forwarding == "on")
if errors:
return RedirectResponse(
f"/settings?error={quote('; '.join(errors))}", status_code=303
)
return RedirectResponse("/settings?message=Applied", status_code=303)
@app.get("/api/status", dependencies=[logged_in])
def api_status(db: Session = Depends(get_db)):
status = wireguard.get_status()
peers = service.list_peers(db)
return {
"interface": {
"name": status.name,
"up": status.up,
"listen_port": status.listen_port,
"total_rx": status.total_rx,
"total_tx": status.total_tx,
},
"peers": [
runtime = service.get_runtime_settings(db)
interfaces = []
for iface in service.list_interfaces(db):
status = wireguard.get_status(iface.name)
peers = service.list_peers(db, iface)
interfaces.append(
{
"id": peer.id,
"name": peer.name,
"address": peer.address,
"enabled": peer.enabled,
"note": peer.note,
"extra_allowed_ips": peer.extra_allowed_ips,
"client_allowed_ips": peer.client_allowed_ips,
"quota_bytes": peer.quota_bytes,
"cum_rx": peer.cum_rx,
"cum_tx": peer.cum_tx,
"over_quota": peer.over_quota,
"online": (ps := status.peers.get(peer.public_key)) is not None and ps.online,
"endpoint": ps.endpoint if ps else None,
"latest_handshake": ps.latest_handshake.isoformat()
if ps and ps.latest_handshake
else None,
"rx_bytes": ps.rx_bytes if ps else 0,
"tx_bytes": ps.tx_bytes if ps else 0,
"id": iface.id,
"name": iface.name,
"up": status.up,
"enabled": iface.enabled,
"imported": iface.imported,
"listen_port": status.listen_port or iface.listen_port,
"address": wireguard.server_address(iface.subnet),
"subnet": iface.subnet,
"host": iface.host,
"peer_isolation": iface.peer_isolation,
"total_rx": status.total_rx,
"total_tx": status.total_tx,
"peers": [
{
"id": peer.id,
"name": peer.name,
"address": peer.address,
"enabled": peer.enabled,
"note": peer.note,
"has_private_key": peer.has_private_key,
"extra_allowed_ips": peer.extra_allowed_ips,
"client_allowed_ips": peer.client_allowed_ips,
"quota_bytes": peer.quota_bytes,
"cum_rx": peer.cum_rx,
"cum_tx": peer.cum_tx,
"over_quota": peer.over_quota,
"online": (ps := status.peers.get(peer.public_key)) is not None
and ps.online,
"endpoint": ps.endpoint if ps else None,
"latest_handshake": ps.latest_handshake.isoformat()
if ps and ps.latest_handshake
else None,
"rx_bytes": ps.rx_bytes if ps else 0,
"tx_bytes": ps.tx_bytes if ps else 0,
}
for peer in peers
],
}
for peer in peers
],
)
return {
"interfaces": interfaces,
"meta": {"refresh_seconds": runtime["ui_refresh_seconds"]},
}
+59 -8
View File
@@ -1,7 +1,7 @@
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .db import Base
@@ -10,15 +10,51 @@ def utcnow() -> datetime:
return datetime.now(timezone.utc)
class Peer(Base):
__tablename__ = "peers"
class Interface(Base):
__tablename__ = "interfaces"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(64), unique=True)
public_key: Mapped[str] = mapped_column(String(64), unique=True)
name: Mapped[str] = mapped_column(String(15), unique=True)
subnet: Mapped[str] = mapped_column(String(64))
listen_port: Mapped[int] = mapped_column(Integer, unique=True)
host: Mapped[str] = mapped_column(String(256))
dns: Mapped[str] = mapped_column(String(128), default="1.1.1.1")
allowed_ips: Mapped[str] = mapped_column(String(512), default="0.0.0.0/0, ::/0")
persistent_keepalive: Mapped[int] = mapped_column(Integer, default=25)
peer_isolation: Mapped[bool] = mapped_column(Boolean, default=False)
mtu: Mapped[int] = mapped_column(Integer, default=0)
mss_clamp: Mapped[bool] = mapped_column(Boolean, default=False)
fwmark: Mapped[str] = mapped_column(String(32), default="")
route_table: Mapped[str] = mapped_column(String(32), default="")
post_up: Mapped[str] = mapped_column(String(2048), default="")
post_down: Mapped[str] = mapped_column(String(2048), default="")
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
private_key_enc: Mapped[str] = mapped_column(String(256))
preshared_key_enc: Mapped[str] = mapped_column(String(256))
address: Mapped[str] = mapped_column(String(64), unique=True)
public_key: Mapped[str] = mapped_column(String(64), unique=True)
imported: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
peers: Mapped[list["Peer"]] = relationship(
back_populates="interface", cascade="all, delete-orphan"
)
class Peer(Base):
__tablename__ = "peers"
__table_args__ = (
UniqueConstraint("interface_id", "name", name="uq_peer_iface_name"),
UniqueConstraint("interface_id", "address", name="uq_peer_iface_address"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
interface_id: Mapped[int] = mapped_column(
ForeignKey("interfaces.id", ondelete="CASCADE"), index=True
)
name: Mapped[str] = mapped_column(String(64))
public_key: Mapped[str] = mapped_column(String(64), unique=True)
private_key_enc: Mapped[str] = mapped_column(String(256), default="")
preshared_key_enc: Mapped[str] = mapped_column(String(256), default="")
address: Mapped[str] = mapped_column(String(64))
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
@@ -26,12 +62,15 @@ class Peer(Base):
dns: Mapped[str] = mapped_column(String(128), default="")
extra_allowed_ips: Mapped[str] = mapped_column(String(512), default="")
client_allowed_ips: Mapped[str] = mapped_column(String(512), default="")
persistent_keepalive: Mapped[int | None] = mapped_column(Integer, nullable=True)
quota_bytes: Mapped[int] = mapped_column(Integer, default=0)
cum_rx: Mapped[int] = mapped_column(Integer, default=0)
cum_tx: Mapped[int] = mapped_column(Integer, default=0)
last_rx: Mapped[int] = mapped_column(Integer, default=0)
last_tx: Mapped[int] = mapped_column(Integer, default=0)
interface: Mapped[Interface] = relationship(back_populates="peers")
@property
def cum_total(self) -> int:
return self.cum_rx + self.cum_tx
@@ -40,11 +79,23 @@ class Peer(Base):
def over_quota(self) -> bool:
return self.quota_bytes > 0 and self.cum_total >= self.quota_bytes
@property
def has_private_key(self) -> bool:
return bool(self.private_key_enc)
class AppSetting(Base):
__tablename__ = "app_settings"
key: Mapped[str] = mapped_column(String(64), primary_key=True)
value: Mapped[str] = mapped_column(String(256), default="")
class TrafficSample(Base):
__tablename__ = "traffic_samples"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
interface_name: Mapped[str] = mapped_column(String(15), default="", index=True)
peer_public_key: Mapped[str] = mapped_column(String(64), index=True)
rx_bytes: Mapped[int] = mapped_column(Integer, default=0)
tx_bytes: Mapped[int] = mapped_column(Integer, default=0)
+433 -46
View File
@@ -1,15 +1,376 @@
from datetime import datetime, timezone
import ipaddress
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from . import wireguard
from .config import settings
from .crypto import encrypt
from .models import Peer, TrafficSample
from .models import AppSetting, Interface, Peer, TrafficSample
RUNTIME_DEFAULTS = {
"traffic_sample_interval": "60",
"traffic_retention_days": "30",
"online_threshold": "180",
"ui_refresh_seconds": "5",
}
def list_peers(db: Session) -> list[Peer]:
return list(db.scalars(select(Peer).order_by(Peer.id)))
def get_runtime_settings(db: Session) -> dict[str, int]:
stored = {s.key: s.value for s in db.scalars(select(AppSetting))}
result = {}
for key, default in RUNTIME_DEFAULTS.items():
try:
result[key] = int(stored.get(key, default))
except ValueError:
result[key] = int(default)
return result
def update_runtime_settings(db: Session, values: dict[str, int]) -> None:
bounds = {
"traffic_sample_interval": (10, 3600),
"traffic_retention_days": (0, 3650),
"online_threshold": (30, 3600),
"ui_refresh_seconds": (2, 300),
}
for key, (low, high) in bounds.items():
if key not in values:
continue
value = values[key]
if not low <= value <= high:
raise ValueError(f"{key} must be between {low} and {high}")
setting = db.get(AppSetting, key)
if setting is None:
db.add(AppSetting(key=key, value=str(value)))
else:
setting.value = str(value)
db.commit()
wireguard.status_module.online_threshold_seconds = get_runtime_settings(db)[
"online_threshold"
]
def prune_traffic_samples(db: Session) -> int:
retention_days = get_runtime_settings(db)["traffic_retention_days"]
if retention_days <= 0:
return 0
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
result = db.execute(
TrafficSample.__table__.delete().where(TrafficSample.sampled_at < cutoff)
)
db.commit()
return result.rowcount or 0
def list_interfaces(db: Session) -> list[Interface]:
return list(db.scalars(select(Interface).order_by(Interface.id)))
def get_interface(db: Session, interface_id: int) -> Interface | None:
return db.get(Interface, interface_id)
def get_interface_by_name(db: Session, name: str) -> Interface | None:
return db.scalar(select(Interface).where(Interface.name == name))
def _check_interface_conflicts(
db: Session, name: str, subnet: str, listen_port: int, exclude_id: int | None = None
) -> None:
for other in list_interfaces(db):
if other.id == exclude_id:
continue
if other.name == name:
raise ValueError(f"Interface {name} already exists")
if other.listen_port == listen_port:
raise ValueError(f"Port {listen_port} is already used by {other.name}")
if ipaddress.ip_network(subnet).overlaps(ipaddress.ip_network(other.subnet)):
raise ValueError(f"Subnet {subnet} overlaps {other.name} ({other.subnet})")
def create_interface(
db: Session,
name: str,
subnet: str,
listen_port: int,
host: str,
dns: str = "",
allowed_ips: str = "",
persistent_keepalive: int = 25,
peer_isolation: bool = False,
) -> Interface:
name = wireguard.validate_interface_name(name)
subnet = wireguard.validate_subnet(subnet)
if not 1 <= listen_port <= 65535:
raise ValueError(f"Invalid port: {listen_port}")
host = host.strip()
if not host:
raise ValueError("Endpoint host is required")
_check_interface_conflicts(db, name, subnet, listen_port)
if name in wireguard.system_interfaces():
raise ValueError(
f"Interface {name} is already running on this host. "
"Import it instead of creating a new one."
)
private, public = wireguard.generate_keypair()
iface = Interface(
name=name,
subnet=subnet,
listen_port=listen_port,
host=host,
dns=dns.strip() or settings.wg_dns,
allowed_ips=wireguard.validate_cidr_list(allowed_ips) or settings.wg_allowed_ips,
persistent_keepalive=persistent_keepalive,
peer_isolation=peer_isolation,
private_key_enc=encrypt(private),
public_key=public,
)
db.add(iface)
db.commit()
apply_config(db, iface)
try:
wireguard.interface_up(iface)
except Exception:
pass
return iface
def _validate_mtu(mtu: int) -> int:
if mtu and not 1280 <= mtu <= 1500:
raise ValueError("MTU must be between 1280 and 1500 (0 = default)")
return mtu
def _validate_mark(value: str, label: str) -> str:
value = value.strip()
if value and not value.replace("x", "").replace("X", "").isalnum():
raise ValueError(f"Invalid {label}: {value}")
return value
def update_interface(
db: Session,
iface: Interface,
host: str,
dns: str,
allowed_ips: str,
persistent_keepalive: int,
peer_isolation: bool,
mtu: int = 0,
mss_clamp: bool = False,
fwmark: str = "",
route_table: str = "",
post_up: str = "",
post_down: str = "",
) -> Interface:
host = host.strip()
if not host:
raise ValueError("Endpoint host is required")
iface.host = host
iface.dns = dns.strip() or settings.wg_dns
iface.allowed_ips = (
wireguard.validate_cidr_list(allowed_ips) or settings.wg_allowed_ips
)
iface.persistent_keepalive = persistent_keepalive
iface.peer_isolation = peer_isolation
iface.mtu = _validate_mtu(mtu)
iface.mss_clamp = mss_clamp
iface.fwmark = _validate_mark(fwmark, "FwMark")
iface.route_table = _validate_mark(route_table, "Table")
iface.post_up = post_up.strip()
iface.post_down = post_down.strip()
db.commit()
apply_config(db, iface)
return iface
def toggle_interface(db: Session, iface: Interface) -> Interface:
if iface.enabled:
wireguard.interface_down(iface)
iface.enabled = False
else:
iface.enabled = True
apply_config(db, iface)
wireguard.interface_up(iface)
db.commit()
return iface
def delete_interface(db: Session, iface: Interface, cascade: bool = False) -> None:
if iface.peers and not cascade:
raise ValueError(
f"Interface {iface.name} still has {len(iface.peers)} peers. "
"Delete them first or use cascade delete."
)
try:
wireguard.interface_down(iface)
except Exception:
pass
wireguard.backup_config(iface.name)
path = wireguard.config_path(iface.name)
if path.exists():
path.unlink()
db.delete(iface)
db.commit()
@dataclass
class ImportCandidate:
name: str
has_config: bool
running: bool
peer_count: int
listen_port: int | None
address: str
def import_candidates(db: Session) -> list[ImportCandidate]:
known = {iface.name for iface in list_interfaces(db)}
names = set(wireguard.discover_configs()) | set(wireguard.system_interfaces())
candidates = []
for name in sorted(names - known):
parsed = None
path = wireguard.config_path(name)
if path.exists():
try:
parsed = wireguard.parse_config(path.read_text())
except Exception:
parsed = None
status = wireguard.get_status(name)
candidates.append(
ImportCandidate(
name=name,
has_config=parsed is not None and bool(parsed.private_key),
running=status.up,
peer_count=len(parsed.peers) if parsed else len(status.peers),
listen_port=(parsed.listen_port if parsed else None) or status.listen_port,
address=parsed.address if parsed else "",
)
)
return candidates
def import_interface(db: Session, name: str, host: str) -> Interface:
name = wireguard.validate_interface_name(name)
if get_interface_by_name(db, name) is not None:
raise ValueError(f"Interface {name} is already managed")
path = wireguard.config_path(name)
if not path.exists():
raise ValueError(
f"No config file at {path}. Only wg-quick configs can be imported."
)
parsed = wireguard.parse_config(path.read_text())
if not parsed.private_key:
raise ValueError(f"{path} has no PrivateKey")
if not parsed.address:
raise ValueError(f"{path} has no Address")
if not parsed.listen_port:
raise ValueError(f"{path} has no ListenPort")
host = host.strip()
if not host:
raise ValueError("Endpoint host is required")
network = ipaddress.ip_interface(parsed.address).network
subnet = str(network)
_check_interface_conflicts(db, name, subnet, parsed.listen_port)
wireguard.backup_config(name)
iface = Interface(
name=name,
subnet=subnet,
listen_port=parsed.listen_port,
host=host,
dns=settings.wg_dns,
allowed_ips=settings.wg_allowed_ips,
persistent_keepalive=settings.wg_persistent_keepalive,
private_key_enc=encrypt(parsed.private_key),
public_key=wireguard.pubkey(parsed.private_key),
imported=True,
)
db.add(iface)
db.flush()
used_names: set[str] = set()
index = 1
for parsed_peer in parsed.peers:
address = ""
extra: list[str] = []
for cidr in parsed_peer.allowed_ips:
try:
net = ipaddress.ip_network(cidr, strict=False)
except ValueError:
continue
if not address and net.prefixlen == net.max_prefixlen and net.network_address in network:
address = f"{net.network_address}/{net.max_prefixlen}"
else:
extra.append(str(net))
if not address:
address = wireguard.next_free_address(
subnet, [p.address for p in iface.peers]
)
peer_name = parsed_peer.name.strip() or f"imported-{index}"
while peer_name in used_names:
index += 1
peer_name = f"imported-{index}"
used_names.add(peer_name)
index += 1
iface.peers.append(
Peer(
name=peer_name,
public_key=parsed_peer.public_key,
private_key_enc="",
preshared_key_enc=(
encrypt(parsed_peer.preshared_key)
if parsed_peer.preshared_key
else ""
),
address=address,
extra_allowed_ips=", ".join(extra),
)
)
db.commit()
apply_config(db, iface)
return iface
def bootstrap_default_interface(db: Session) -> None:
if list_interfaces(db):
return
private_path = settings.data_dir / "server" / "privatekey"
if private_path.exists():
private = private_path.read_text().strip()
else:
private = wireguard.genkey()
iface = Interface(
name=settings.wg_interface,
subnet=settings.wg_subnet,
listen_port=settings.wg_port,
host=settings.wg_host,
dns=settings.wg_dns,
allowed_ips=settings.wg_allowed_ips,
persistent_keepalive=settings.wg_persistent_keepalive,
peer_isolation=settings.wg_peer_isolation,
private_key_enc=encrypt(private),
public_key=wireguard.pubkey(private),
)
db.add(iface)
db.commit()
db.execute(
Peer.__table__.update()
.where(Peer.interface_id.notin_(select(Interface.id)))
.values(interface_id=iface.id)
)
db.commit()
def list_peers(db: Session, iface: Interface | None = None) -> list[Peer]:
query = select(Peer).order_by(Peer.id)
if iface is not None:
query = query.where(Peer.interface_id == iface.id)
return list(db.scalars(query))
def get_peer(db: Session, peer_id: int) -> Peer | None:
@@ -18,6 +379,7 @@ def get_peer(db: Session, peer_id: int) -> Peer | None:
def create_peer(
db: Session,
iface: Interface,
name: str,
expires_at: datetime | None = None,
note: str = "",
@@ -27,14 +389,17 @@ def create_peer(
extra_allowed_ips: str = "",
client_allowed_ips: str = "",
) -> Peer:
if any(p.name == name for p in list_peers(db, iface)):
raise ValueError(f"Peer {name} already exists on {iface.name}")
private, public = wireguard.generate_keypair()
psk = wireguard.genpsk()
taken = [p.address for p in list_peers(db)]
taken = [p.address for p in list_peers(db, iface)]
if address:
address = wireguard.validate_address(address, taken)
address = wireguard.validate_address(address, iface.subnet, taken)
else:
address = wireguard.next_free_address(taken)
address = wireguard.next_free_address(iface.subnet, taken)
peer = Peer(
interface_id=iface.id,
name=name,
public_key=public,
private_key_enc=encrypt(private),
@@ -49,19 +414,20 @@ def create_peer(
)
db.add(peer)
db.commit()
apply_config(db)
apply_config(db, iface)
return peer
def create_peers_batch(
db: Session,
iface: Interface,
base_name: str,
count: int,
expires_at: datetime | None = None,
note: str = "",
quota_bytes: int = 0,
) -> list[Peer]:
existing = {p.name for p in list_peers(db)}
existing = {p.name for p in list_peers(db, iface)}
peers = []
index = 1
for _ in range(count):
@@ -69,7 +435,7 @@ def create_peers_batch(
index += 1
name = f"{base_name}-{index}"
existing.add(name)
peers.append(create_peer(db, name, expires_at, note, quota_bytes))
peers.append(create_peer(db, iface, name, expires_at, note, quota_bytes))
return peers
@@ -81,15 +447,19 @@ def update_peer(
dns: str = "",
extra_allowed_ips: str = "",
client_allowed_ips: str = "",
persistent_keepalive: int | None = None,
) -> Peer:
peer.note = note
peer.quota_bytes = quota_bytes
peer.dns = dns
peer.extra_allowed_ips = wireguard.validate_cidr_list(extra_allowed_ips)
peer.client_allowed_ips = wireguard.validate_cidr_list(client_allowed_ips)
if persistent_keepalive is not None and not 0 <= persistent_keepalive <= 3600:
raise ValueError("Keepalive must be between 0 and 3600")
peer.persistent_keepalive = persistent_keepalive
db.commit()
if not peer.over_quota:
apply_config(db)
apply_config(db, peer.interface)
return peer
@@ -97,7 +467,7 @@ def reset_peer_usage(db: Session, peer: Peer) -> Peer:
peer.cum_rx = 0
peer.cum_tx = 0
db.commit()
apply_config(db)
apply_config(db, peer.interface)
return peer
@@ -109,26 +479,27 @@ def rotate_peer_keys(db: Session, peer: Peer) -> Peer:
peer.private_key_enc = encrypt(private)
peer.preshared_key_enc = encrypt(wireguard.genpsk())
db.commit()
apply_config(db)
apply_config(db, peer.interface)
return peer
def toggle_peer(db: Session, peer: Peer) -> Peer:
peer.enabled = not peer.enabled
db.commit()
apply_config(db)
apply_config(db, peer.interface)
return peer
def delete_peer(db: Session, peer: Peer) -> None:
iface = peer.interface
db.delete(peer)
db.commit()
apply_config(db)
apply_config(db, iface)
def disable_expired_peers(db: Session) -> bool:
now = datetime.now(timezone.utc)
changed = False
changed_ifaces = []
for peer in list_peers(db):
if not peer.enabled:
continue
@@ -137,47 +508,63 @@ def disable_expired_peers(db: Session) -> bool:
expires = expires.replace(tzinfo=timezone.utc)
if (expires is not None and expires <= now) or peer.over_quota:
peer.enabled = False
changed = True
if changed:
if peer.interface not in changed_ifaces:
changed_ifaces.append(peer.interface)
if changed_ifaces:
db.commit()
apply_config(db)
return changed
for iface in changed_ifaces:
apply_config(db, iface)
return bool(changed_ifaces)
def accumulate_usage(db: Session) -> None:
status = wireguard.get_status()
changed = False
for peer in list_peers(db):
peer_status = status.peers.get(peer.public_key)
if peer_status is None:
continue
rx, tx = peer_status.rx_bytes, peer_status.tx_bytes
# wg counters reset on interface restart or peer re-add
delta_rx = rx - peer.last_rx if rx >= peer.last_rx else rx
delta_tx = tx - peer.last_tx if tx >= peer.last_tx else tx
if delta_rx or delta_tx or rx != peer.last_rx or tx != peer.last_tx:
peer.cum_rx += delta_rx
peer.cum_tx += delta_tx
peer.last_rx = rx
peer.last_tx = tx
changed = True
for iface in list_interfaces(db):
status = wireguard.get_status(iface.name)
for peer in list_peers(db, iface):
peer_status = status.peers.get(peer.public_key)
if peer_status is None:
continue
rx, tx = peer_status.rx_bytes, peer_status.tx_bytes
# wg counters reset on interface restart or peer re-add
delta_rx = rx - peer.last_rx if rx >= peer.last_rx else rx
delta_tx = tx - peer.last_tx if tx >= peer.last_tx else tx
if delta_rx or delta_tx or rx != peer.last_rx or tx != peer.last_tx:
peer.cum_rx += delta_rx
peer.cum_tx += delta_tx
peer.last_rx = rx
peer.last_tx = tx
changed = True
if changed:
db.commit()
def apply_config(db: Session) -> None:
private, _ = wireguard.ensure_server_keys()
wireguard.sync_peers(private, list_peers(db))
def apply_config(db: Session, iface: Interface) -> None:
if not iface.enabled:
return
wireguard.sync_peers(iface, list_peers(db, iface))
def apply_all_configs(db: Session) -> None:
for iface in list_interfaces(db):
try:
apply_config(db, iface)
if iface.enabled:
wireguard.interface_up(iface)
except Exception:
pass
def sample_traffic(db: Session) -> None:
status = wireguard.get_status()
for peer_status in status.peers.values():
db.add(
TrafficSample(
peer_public_key=peer_status.public_key,
rx_bytes=peer_status.rx_bytes,
tx_bytes=peer_status.tx_bytes,
for iface in list_interfaces(db):
status = wireguard.get_status(iface.name)
for peer_status in status.peers.values():
db.add(
TrafficSample(
interface_name=iface.name,
peer_public_key=peer_status.public_key,
rx_bytes=peer_status.rx_bytes,
tx_bytes=peer_status.tx_bytes,
)
)
)
db.commit()
+45
View File
File diff suppressed because one or more lines are too long
+36 -30
View File
@@ -1,4 +1,4 @@
const REFRESH_MS = 5000;
let refreshMs = 5000;
function fmtBytes(num) {
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
@@ -28,39 +28,45 @@ async function refresh() {
return;
}
const online = data.peers.filter((p) => p.online).length;
const set = (id, value) => {
const el = document.getElementById(id);
if (el) el.textContent = value;
};
set("peer-count", data.peers.length);
set("online-count", online);
set("total-rx", fmtBytes(data.interface.total_rx));
set("total-tx", fmtBytes(data.interface.total_tx));
if (data.meta && data.meta.refresh_seconds) {
refreshMs = data.meta.refresh_seconds * 1000;
}
const tbody = document.querySelector("#peer-table tbody");
if (!tbody) return;
const badgeBase = "rounded-full px-2 py-0.5 text-xs font-medium";
tbody.innerHTML = "";
for (const p of data.peers) {
const tr = document.createElement("tr");
tr.className = "hover:bg-slate-50";
const badge = p.online
? `<span class="${badgeBase} bg-emerald-100 text-emerald-700">online</span>`
: p.enabled
? `<span class="${badgeBase} bg-slate-100 text-slate-500">offline</span>`
: `<span class="${badgeBase} bg-red-100 text-red-700">disabled</span>`;
tr.innerHTML =
`<td class="px-4 py-3"><a href="/peers/${p.id}" class="font-medium text-blue-600 hover:underline"></a></td>` +
`<td class="px-4 py-3 font-mono text-xs">${p.address}</td>` +
`<td class="px-4 py-3">${badge}</td>` +
`<td class="px-4 py-3 text-slate-500">${fmtHandshake(p.latest_handshake)}</td>` +
`<td class="px-4 py-3 text-slate-500">${fmtBytes(p.rx_bytes)}</td>` +
`<td class="px-4 py-3 text-slate-500">${fmtBytes(p.tx_bytes)}</td>`;
tr.querySelector("a").textContent = p.name;
tbody.appendChild(tr);
const esc = (s) =>
String(s).replace(/[&<>"]/g, (ch) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[ch]
);
let html = "";
for (const iface of data.interfaces) {
for (const p of iface.peers) {
const badge = p.online
? `<span class="${badgeBase} bg-emerald-100 text-emerald-700">online</span>`
: p.enabled
? `<span class="${badgeBase} bg-slate-100 text-slate-500">offline</span>`
: `<span class="${badgeBase} bg-red-100 text-red-700">disabled</span>`;
html +=
`<tr class="hover:bg-slate-50">` +
`<td class="truncate px-4 py-3"><a href="/peers/${p.id}" class="font-medium text-blue-600 hover:underline">${esc(p.name)}</a></td>` +
`<td class="truncate px-4 py-3 text-slate-500">${esc(iface.name)}</td>` +
`<td class="truncate px-4 py-3 font-mono text-xs">${esc(p.address)}</td>` +
`<td class="px-4 py-3">${badge}</td>` +
`<td class="whitespace-nowrap px-4 py-3 text-slate-500 tabular-nums">${fmtHandshake(p.latest_handshake)}</td>` +
`<td class="whitespace-nowrap px-4 py-3 text-slate-500 tabular-nums">${fmtBytes(p.rx_bytes)}</td>` +
`<td class="whitespace-nowrap px-4 py-3 text-slate-500 tabular-nums">${fmtBytes(p.tx_bytes)}</td>` +
`</tr>`;
}
}
if (tbody.dataset.html !== html) {
tbody.dataset.html = html;
tbody.innerHTML = html;
}
}
refresh();
setInterval(refresh, REFRESH_MS);
async function loop() {
await refresh();
setTimeout(loop, refreshMs);
}
loop();
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+209
View File
@@ -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();
+3 -1
View File
@@ -4,14 +4,16 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}WireGuard Admin{% endblock %}</title>
<script src="/static/tailwind.js"></script>
<link rel="stylesheet" href="/static/tailwind.css">
</head>
<body class="min-h-screen bg-slate-100 text-slate-800">
<nav class="bg-slate-900 text-slate-300">
<div class="mx-auto flex max-w-5xl items-center gap-6 px-6 py-3">
<span class="text-base font-bold tracking-wide text-white">WireGuard Admin</span>
<a href="/" class="text-sm hover:text-white">Dashboard</a>
<a href="/interfaces" class="text-sm hover:text-white">Interfaces</a>
<a href="/peers" class="text-sm hover:text-white">Peers</a>
<a href="/topology" class="text-sm hover:text-white">Topology</a>
<a href="/settings" class="text-sm hover:text-white">Settings</a>
<form method="post" action="/logout" class="ml-auto">
<button type="submit" class="text-sm text-slate-400 hover:text-white">Logout</button>
+28 -22
View File
@@ -4,44 +4,50 @@
{% block content %}
<h1 class="mb-6 text-2xl font-bold">Dashboard</h1>
<div class="mb-8 grid gap-4 sm:grid-cols-3">
<div id="iface-cards" class="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{% for item in overview %}
<div class="rounded-xl bg-white p-5 shadow-sm">
<h3 class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-400">Interface</h3>
<h3 class="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-slate-400">
Interface
{% if item.iface.imported %}
<span class="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium normal-case text-amber-700">imported</span>
{% endif %}
</h3>
<p class="flex items-center gap-2 text-lg font-semibold">
{{ status.name }}
{% if status.up %}
<a href="/peers?interface={{ item.iface.id }}" class="text-blue-600 hover:underline">{{ item.iface.name }}</a>
{% if item.status.up %}
<span class="rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700">up</span>
{% else %}
<span class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700">down</span>
{% endif %}
</p>
<p class="mt-1 text-sm text-slate-500">
<span class="font-mono">{{ server_address }}</span> · Port {{ status.listen_port or settings.wg_port }}
<span class="font-mono">{{ server_address(item.iface.subnet) }}</span> · Port {{ item.status.listen_port or item.iface.listen_port }}
</p>
<p class="mt-1 text-sm text-slate-500">
{{ item.online }} / {{ item.peers | length }} peers online ·
RX {{ item.status.total_rx | fmt_bytes }} / TX {{ item.status.total_tx | fmt_bytes }}
</p>
</div>
<div class="rounded-xl bg-white p-5 shadow-sm">
<h3 class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-400">Peers</h3>
<p class="text-lg font-semibold"><span id="peer-count">{{ peers | length }}</span> total</p>
<p class="mt-1 text-sm text-slate-500"><span id="online-count">-</span> online</p>
</div>
<div class="rounded-xl bg-white p-5 shadow-sm">
<h3 class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-400">Traffic</h3>
<p class="text-sm">RX <span id="total-rx" class="font-semibold">{{ status.total_rx | fmt_bytes }}</span></p>
<p class="mt-1 text-sm">TX <span id="total-tx" class="font-semibold">{{ status.total_tx | fmt_bytes }}</span></p>
</div>
{% else %}
<p class="rounded-xl bg-white p-6 text-center text-slate-400 shadow-sm sm:col-span-2 lg:col-span-3">
No interfaces. <a href="/interfaces" class="text-blue-600 hover:underline">Create or import one.</a>
</p>
{% endfor %}
</div>
<h2 class="mb-3 text-lg font-semibold">Peers</h2>
<div class="overflow-hidden rounded-xl bg-white shadow-sm">
<table id="peer-table" class="w-full text-sm">
<table id="peer-table" class="w-full table-fixed text-sm">
<thead>
<tr class="bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-400">
<th class="px-4 py-3 font-semibold">Name</th>
<th class="px-4 py-3 font-semibold">Address</th>
<th class="px-4 py-3 font-semibold">Status</th>
<th class="px-4 py-3 font-semibold">Handshake</th>
<th class="px-4 py-3 font-semibold">RX</th>
<th class="px-4 py-3 font-semibold">TX</th>
<th class="w-[18%] px-4 py-3 font-semibold">Name</th>
<th class="w-[12%] px-4 py-3 font-semibold">Interface</th>
<th class="w-[18%] px-4 py-3 font-semibold">Address</th>
<th class="w-[12%] px-4 py-3 font-semibold">Status</th>
<th class="w-[14%] px-4 py-3 font-semibold">Handshake</th>
<th class="w-[13%] px-4 py-3 font-semibold">RX</th>
<th class="w-[13%] px-4 py-3 font-semibold">TX</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100"></tbody>
+261
View File
@@ -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 %}
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Login - WireGuard Admin</title>
<script src="/static/tailwind.js"></script>
<link rel="stylesheet" href="/static/tailwind.css">
</head>
<body class="flex min-h-screen items-center justify-center bg-slate-900">
<div class="w-80 rounded-xl bg-white p-8 shadow-2xl">
+36 -1
View File
@@ -6,7 +6,13 @@
<p class="mb-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">{{ error }}</p>
{% endif %}
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold">{{ peer.name }}</h1>
<h1 class="flex items-center gap-2 text-2xl font-bold">
{{ peer.name }}
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-500">{{ iface.name }}</span>
{% if not peer.has_private_key %}
<span class="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700">imported</span>
{% endif %}
</h1>
<div class="flex gap-2">
<form method="post" action="/peers/{{ peer.id }}/toggle">
<button type="submit"
@@ -96,6 +102,13 @@
value="{{ '%.1f' % (peer.quota_bytes / 1073741824) if peer.quota_bytes else '' }}"
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
</label>
<label class="block text-sm font-medium text-slate-600">Keepalive (s)
<input name="persistent_keepalive" type="number" min="0" max="3600"
value="{{ peer.persistent_keepalive if peer.persistent_keepalive is not none else '' }}"
placeholder="interface default ({{ iface.persistent_keepalive }})"
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
<span class="mt-1 block text-xs text-slate-400">0 disables keepalive (fixed-IP site-to-site peers). Empty = interface default.</span>
</label>
<label class="block text-sm font-medium text-slate-600">Site subnets behind this peer
<input name="extra_allowed_ips" value="{{ peer.extra_allowed_ips }}" placeholder="e.g. 192.168.5.0/24"
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
@@ -147,6 +160,7 @@
<div class="rounded-xl bg-white p-5 shadow-sm">
<h3 class="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-400">Client config</h3>
{% if peer.has_private_key %}
<img src="/peers/{{ peer.id }}/qr" alt="QR code" class="mx-auto w-48 rounded-lg border border-slate-200">
<div class="mt-4 flex justify-center gap-2">
<a href="/peers/{{ peer.id }}/config"
@@ -158,14 +172,34 @@
Copy
</button>
</div>
{% else %}
<p class="text-sm text-slate-500">
This peer was imported from an existing config, so the server only knows its public key.
The client keeps using its current config and everything works, but the panel cannot
generate a .conf or QR code.
</p>
<p class="mt-3 text-sm text-slate-500">
To manage the client config from here, rotate its keys and install the new config on the device.
</p>
<form method="post" action="/peers/{{ peer.id }}/rotate" class="mt-4 text-center"
onsubmit="return confirm('Rotate keys? The device stays offline until the new config is installed.')">
<button type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700">
Rotate keys and generate config
</button>
</form>
{% endif %}
</div>
</div>
{% if client_config %}
<div class="mt-4 rounded-xl bg-white p-5 shadow-sm">
<h3 class="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-400">{{ peer.name }}.conf</h3>
<pre id="client-config" class="overflow-x-auto rounded-lg bg-slate-900 p-4 text-xs leading-relaxed text-slate-200">{{ client_config }}</pre>
</div>
{% endif %}
{% if client_config %}
<div class="mt-4 rounded-xl bg-white p-5 shadow-sm">
<h3 class="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-400">Client setup guide</h3>
<div class="space-y-5 text-sm">
@@ -205,6 +239,7 @@ nmcli connection up {{ peer.name }}</pre>
</div>
</div>
</div>
{% endif %}
<script>
function copyConfig(btn) {
+25 -4
View File
@@ -8,9 +8,26 @@
<p class="mb-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">{{ error }}</p>
{% endif %}
<div class="mb-6 flex flex-wrap gap-1 border-b border-slate-200">
{% for iface in interfaces %}
<a href="/peers?interface={{ iface.id }}"
class="rounded-t-md px-4 py-2 text-sm font-medium {% if current and iface.id == current.id %}border border-b-0 border-slate-200 bg-white text-blue-600{% else %}text-slate-500 hover:text-slate-700{% endif %}">
{{ iface.name }}
<span class="ml-1 rounded-full bg-slate-100 px-1.5 text-xs text-slate-500">{{ iface.peers | length }}</span>
</a>
{% endfor %}
<a href="/interfaces" class="ml-auto px-4 py-2 text-sm text-slate-400 hover:text-slate-600">Manage interfaces</a>
</div>
{% if not current %}
<p class="rounded-xl bg-white p-6 text-center text-slate-400 shadow-sm">
No interfaces configured. <a href="/interfaces" class="text-blue-600 hover:underline">Create one first.</a>
</p>
{% else %}
<div class="mb-8 rounded-xl bg-white p-5 shadow-sm">
<h2 class="mb-4 text-xs font-semibold uppercase tracking-wide text-slate-400">Add peer</h2>
<h2 class="mb-4 text-xs font-semibold uppercase tracking-wide text-slate-400">Add peer to {{ current.name }}</h2>
<form method="post" action="/peers" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<input type="hidden" name="interface_id" value="{{ current.id }}">
<label class="block text-sm font-medium text-slate-600">Name
<input name="name" required placeholder="laptop"
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
@@ -24,7 +41,7 @@
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
</label>
<label class="block text-sm font-medium text-slate-600">DNS
<input name="dns" placeholder="{{ settings.wg_dns }}"
<input name="dns" placeholder="{{ current.dns }}"
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
</label>
<label class="block text-sm font-medium text-slate-600">Quota (GiB)
@@ -47,9 +64,9 @@
<input name="client_allowed_ips" placeholder="server default"
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
</label>
<div class="flex items-end">
<div class="col-span-full">
<button type="submit"
class="w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700">
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700">
Add peer(s)
</button>
</div>
@@ -78,6 +95,9 @@
<tr class="hover:bg-slate-50">
<td class="px-4 py-3">
<a href="/peers/{{ peer.id }}" class="font-medium text-blue-600 hover:underline">{{ peer.name }}</a>
{% if not peer.has_private_key %}
<span class="ml-1 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700">imported</span>
{% endif %}
</td>
<td class="px-4 py-3 font-mono text-xs">{{ peer.address }}</td>
<td class="px-4 py-3">
@@ -124,4 +144,5 @@
</tbody>
</table>
</div>
{% endif %}
{% endblock %}
+122 -26
View File
@@ -3,32 +3,125 @@
{% block breadcrumbs %}<span>/</span> <span>Settings</span>{% endblock %}
{% block content %}
<h1 class="mb-6 text-2xl font-bold">Settings</h1>
<div class="rounded-xl bg-white p-5 shadow-sm">
{% if error %}
<p class="mb-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">{{ error }}</p>
{% endif %}
{% if message %}
<p class="mb-4 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{{ message }}</p>
{% endif %}
<div class="mb-6 rounded-xl bg-white p-5 shadow-sm">
<h2 class="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-400">Monitoring</h2>
<form method="post" action="/settings/runtime" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<label class="block text-sm font-medium text-slate-600">Sample interval (s)
<input name="traffic_sample_interval" type="number" min="10" max="3600" value="{{ runtime.traffic_sample_interval }}"
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
<span class="mt-1 block text-xs text-slate-400">Traffic/quota/expiry check frequency.</span>
</label>
<label class="block text-sm font-medium text-slate-600">Sample retention (days)
<input name="traffic_retention_days" type="number" min="0" max="3650" value="{{ runtime.traffic_retention_days }}"
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
<span class="mt-1 block text-xs text-slate-400">Old samples are pruned. 0 = keep forever.</span>
</label>
<label class="block text-sm font-medium text-slate-600">Online threshold (s)
<input name="online_threshold" type="number" min="30" max="3600" value="{{ runtime.online_threshold }}"
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
<span class="mt-1 block text-xs text-slate-400">Max handshake age to count a peer online.</span>
</label>
<label class="block text-sm font-medium text-slate-600">UI refresh (s)
<input name="ui_refresh_seconds" type="number" min="2" max="300" value="{{ runtime.ui_refresh_seconds }}"
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
<span class="mt-1 block text-xs text-slate-400">Dashboard and topology poll interval.</span>
</label>
<div class="col-span-full">
<button type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700">
Save
</button>
</div>
</form>
</div>
<div class="mb-6 rounded-xl bg-white p-5 shadow-sm">
<h2 class="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-400">Host performance tuning</h2>
<dl class="mb-4 grid gap-x-8 gap-y-1 text-sm sm:grid-cols-2 lg:grid-cols-4">
<div class="flex justify-between gap-2 sm:block">
<dt class="text-slate-500">UDP rmem_max</dt>
<dd class="font-mono text-xs">{{ tuning.rmem_max or "?" }}</dd>
</div>
<div class="flex justify-between gap-2 sm:block">
<dt class="text-slate-500">UDP wmem_max</dt>
<dd class="font-mono text-xs">{{ tuning.wmem_max or "?" }}</dd>
</div>
<div class="flex justify-between gap-2 sm:block">
<dt class="text-slate-500">netdev_max_backlog</dt>
<dd class="font-mono text-xs">{{ tuning.netdev_max_backlog or "?" }}</dd>
</div>
<div class="flex justify-between gap-2 sm:block">
<dt class="text-slate-500">GRO forwarding ({{ tuning.out_interface or "?" }})</dt>
<dd class="font-mono text-xs">{{ tuning.gro_forwarding or "unknown" }}</dd>
</div>
</dl>
<form method="post" action="/settings/tuning" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<label class="block text-sm font-medium text-slate-600">UDP buffers (MiB)
<input name="udp_buffer_mib" type="number" min="0" max="64" placeholder="16 recommended"
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
<span class="mt-1 block text-xs text-slate-400">rmem_max + wmem_max. Helps &gt;500 Mbps tunnels.</span>
</label>
<label class="block text-sm font-medium text-slate-600">netdev_max_backlog
<input name="netdev_backlog" type="number" min="0" max="100000" placeholder="e.g. 16384"
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
<span class="mt-1 block text-xs text-slate-400">Receive queue for high packet rates.</span>
</label>
<label class="block text-sm font-medium text-slate-600">UDP GRO forwarding
<select name="gro_forwarding"
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
<option value="">leave unchanged</option>
<option value="on">enable</option>
<option value="off">disable</option>
</select>
<span class="mt-1 block text-xs text-slate-400">Boosts relay/site-to-site throughput.</span>
</label>
<div class="col-span-full">
<button type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700">
Apply
</button>
</div>
</form>
<p class="mt-3 text-xs text-slate-400">
Applied immediately to the running kernel (requires host network mode and NET_ADMIN).
Empty fields are left unchanged. Settings do not survive host reboots; persist them in
/etc/sysctl.d on the host if needed.
</p>
</div>
<div class="mb-6 rounded-xl bg-white p-5 shadow-sm">
<h2 class="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-400">Interfaces</h2>
<dl class="divide-y divide-slate-100 text-sm">
{% for iface in interfaces %}
<div class="flex justify-between gap-4 py-3">
<dt class="text-slate-500">Interface</dt>
<dd class="font-medium">{{ settings.wg_interface }}</dd>
</div>
<div class="flex justify-between gap-4 py-3">
<dt class="text-slate-500">Server address</dt>
<dd class="font-mono text-xs">{{ server_address }}</dd>
</div>
<div class="flex justify-between gap-4 py-3">
<dt class="shrink-0 text-slate-500">Server public key</dt>
<dd class="break-all text-right font-mono text-xs">{{ server_public }}</dd>
</div>
<div class="flex justify-between gap-4 py-3">
<dt class="text-slate-500">Endpoint host</dt>
<dd class="font-medium">{{ settings.wg_host }}</dd>
</div>
<div class="flex justify-between gap-4 py-3">
<dt class="text-slate-500">Listen port</dt>
<dd class="font-medium">{{ settings.wg_port }}</dd>
</div>
<div class="flex justify-between gap-4 py-3">
<dt class="text-slate-500">Subnet</dt>
<dd class="font-mono text-xs">{{ settings.wg_subnet }}</dd>
<dt class="text-slate-500">{{ iface.name }}</dt>
<dd class="text-right">
<span class="font-mono text-xs">{{ server_address(iface.subnet) }}</span>
· {{ iface.host }}:{{ iface.listen_port }}
<span class="ml-2 block break-all font-mono text-xs text-slate-400 sm:inline">{{ iface.public_key }}</span>
</dd>
</div>
{% else %}
<p class="py-3 text-slate-400">No interfaces configured.</p>
{% endfor %}
</dl>
<p class="mt-2 text-xs text-slate-400">
Interfaces are managed in the database. Edit them on the
<a href="/interfaces" class="text-blue-600 hover:underline">Interfaces</a> page.
</p>
</div>
<div class="rounded-xl bg-white p-5 shadow-sm">
<h2 class="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-400">Defaults for new interfaces</h2>
<dl class="divide-y divide-slate-100 text-sm">
<div class="flex justify-between gap-4 py-3">
<dt class="text-slate-500">Client DNS</dt>
<dd class="font-medium">{{ settings.wg_dns }}</dd>
@@ -42,10 +135,13 @@
<dd class="font-medium">{{ settings.wg_persistent_keepalive }}s</dd>
</div>
<div class="flex justify-between gap-4 py-3">
<dt class="text-slate-500">Peer isolation</dt>
<dd class="font-medium">{{ "on" if settings.wg_peer_isolation else "off" }}</dd>
<dt class="text-slate-500">Config directory</dt>
<dd class="font-mono text-xs">{{ settings.wg_config_dir }}</dd>
</div>
</dl>
<p class="mt-4 text-xs text-slate-400">Settings are read from environment variables / .env and require a restart to change.</p>
<p class="mt-4 text-xs text-slate-400">
These defaults come from environment variables / .env and are used when creating the first
interface on startup and as fallbacks for new interfaces.
</p>
</div>
{% endblock %}
+22
View File
@@ -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 %}
-262
View File
@@ -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)
+75
View File
@@ -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",
]
+73
View File
@@ -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)
+188
View File
@@ -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
+24
View File
@@ -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)
+8
View File
@@ -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()
+81
View File
@@ -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()
+118
View File
@@ -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)
+92
View File
@@ -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
View File
@@ -5,17 +5,13 @@ services:
restart: unless-stopped
cap_add:
- NET_ADMIN
sysctls:
- net.ipv4.ip_forward=1
- net.ipv4.conf.all.src_valid_mark=1
# Host networking: new interfaces and their UDP ports work without
# editing port mappings. The admin UI listens on host port 8000.
# 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:
- /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:
WG_INTERFACE: ${WG_INTERFACE:-wg1}
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}
SECRET_KEY: ${SECRET_KEY:?set SECRET_KEY in .env}
volumes:
- ./wg-admin-data:/opt/wireguard-admin/data
- ./wg-config:/etc/wireguard
- ./volumes/admin-data:/opt/wireguard-admin/data
- ./volumes/config:/etc/wireguard
+8 -17
View File
@@ -1,26 +1,17 @@
#!/bin/sh
set -e
WG_INTERFACE="${WG_INTERFACE:-wg0}"
WG_SUBNET="${WG_SUBNET:-10.8.0.0/24}"
OUT_IFACE="$(ip route show default | awk '/default/ {print $5; exit}')"
# Host network mode shares the host network namespace, so forwarding is
# enabled here instead of via per-container sysctls.
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 \
|| iptables -t nat -A POSTROUTING -s "$WG_SUBNET" -o "$OUT_IFACE" -j MASQUERADE
# 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
# NAT and peer isolation rules are managed per interface by the app
# (app/wireguard/sync.py) when configs are applied.
# 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=','
for subnet in ${WG_RELAY_SUBNETS:-}; do
subnet="$(echo "$subnet" | tr -d ' ')"
+5
View File
@@ -0,0 +1,5 @@
module.exports = {
content: ["./app/templates/**/*.html", "./app/static/*.js"],
theme: { extend: {} },
plugins: [],
};