Initial WireGuard admin panel: FastAPI UI, peer key management, live monitoring, Docker deploy

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-07-03 22:23:14 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
commit 998dfe702c
23 changed files with 1128 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
+40
View File
@@ -0,0 +1,40 @@
import bcrypt
from fastapi import Depends, HTTPException, Request, status
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from .config import settings
SESSION_COOKIE = "wg_admin_session"
_serializer = URLSafeTimedSerializer(settings.secret_key, salt="wg-admin-session")
_password_hash = bcrypt.hashpw(settings.admin_password.encode(), bcrypt.gensalt())
def verify_credentials(username: str, password: str) -> bool:
if username != settings.admin_username:
return False
return bcrypt.checkpw(password.encode(), _password_hash)
def create_session_token() -> str:
return _serializer.dumps({"user": settings.admin_username})
def validate_session_token(token: str) -> bool:
try:
_serializer.loads(token, max_age=settings.session_max_age)
return True
except (BadSignature, SignatureExpired):
return False
def require_login(request: Request) -> None:
token = request.cookies.get(SESSION_COOKIE)
if not token or not validate_session_token(token):
raise HTTPException(
status_code=status.HTTP_303_SEE_OTHER,
headers={"Location": "/login"},
)
logged_in = Depends(require_login)
+27
View File
@@ -0,0 +1,27 @@
from pathlib import Path
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
wg_interface: str = "wg0"
wg_host: str = "127.0.0.1"
wg_port: int = 51820
wg_subnet: str = "10.8.0.0/24"
wg_dns: str = "1.1.1.1"
wg_allowed_ips: str = "0.0.0.0/0, ::/0"
wg_persistent_keepalive: int = 25
wg_config_dir: Path = Path("/etc/wireguard")
admin_username: str = "admin"
admin_password: str = "changeme"
secret_key: str = "change-this-secret-key"
session_max_age: int = 12 * 3600
data_dir: Path = Path("data")
model_config = {"env_file": ".env"}
settings = Settings()
settings.data_dir.mkdir(parents=True, exist_ok=True)
+19
View File
@@ -0,0 +1,19 @@
import base64
import hashlib
from cryptography.fernet import Fernet
from .config import settings
def _fernet() -> Fernet:
digest = hashlib.sha256(settings.secret_key.encode()).digest()
return Fernet(base64.urlsafe_b64encode(digest))
def encrypt(value: str) -> str:
return _fernet().encrypt(value.encode()).decode()
def decrypt(value: str) -> str:
return _fernet().decrypt(value.encode()).decode()
+23
View File
@@ -0,0 +1,23 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from .config import settings
class Base(DeclarativeBase):
pass
engine = create_engine(
f"sqlite:///{settings.data_dir / 'wireguard.db'}",
connect_args={"check_same_thread": False},
)
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
def get_db():
db: Session = SessionLocal()
try:
yield db
finally:
db.close()
+224
View File
@@ -0,0 +1,224 @@
import asyncio
import io
from contextlib import asynccontextmanager
from datetime import datetime
import qrcode
from fastapi import Depends, FastAPI, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse, Response, StreamingResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from . import service, wireguard
from .auth import SESSION_COOKIE, create_session_token, logged_in, verify_credentials
from .config import settings
from .db import Base, SessionLocal, engine, get_db
TRAFFIC_SAMPLE_INTERVAL = 300
templates = Jinja2Templates(directory="app/templates")
async def _background_sampler() -> None:
while True:
await asyncio.sleep(TRAFFIC_SAMPLE_INTERVAL)
db = SessionLocal()
try:
service.disable_expired_peers(db)
service.sample_traffic(db)
except Exception:
pass
finally:
db.close()
@asynccontextmanager
async def lifespan(app: FastAPI):
Base.metadata.create_all(engine)
db = SessionLocal()
try:
service.apply_config(db)
try:
wireguard.interface_up()
except Exception:
pass
finally:
db.close()
task = asyncio.create_task(_background_sampler())
yield
task.cancel()
app = FastAPI(title="WireGuard Admin", lifespan=lifespan)
app.mount("/static", StaticFiles(directory="app/static"), name="static")
def _fmt_bytes(num: float) -> str:
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
if abs(num) < 1024:
return f"{num:.1f} {unit}"
num /= 1024
return f"{num:.1f} PiB"
templates.env.filters["fmt_bytes"] = _fmt_bytes
@app.get("/login", response_class=HTMLResponse)
def login_page(request: Request):
return templates.TemplateResponse(request, "login.html", {"error": None})
@app.post("/login")
def login(request: Request, username: str = Form(...), password: str = Form(...)):
if not verify_credentials(username, password):
return templates.TemplateResponse(
request, "login.html", {"error": "Invalid username or password"}, status_code=401
)
response = RedirectResponse("/", status_code=303)
response.set_cookie(
SESSION_COOKIE,
create_session_token(),
max_age=settings.session_max_age,
httponly=True,
samesite="lax",
)
return response
@app.post("/logout")
def logout():
response = RedirectResponse("/login", status_code=303)
response.delete_cookie(SESSION_COOKIE)
return response
@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",
{"status": status, "peers": peers, "settings": settings},
)
@app.get("/peers", response_class=HTMLResponse, dependencies=[logged_in])
def peers_page(request: Request, db: Session = Depends(get_db)):
status = wireguard.get_status()
peers = service.list_peers(db)
return templates.TemplateResponse(
request, "peers.html", {"peers": peers, "status": status}
)
@app.post("/peers", dependencies=[logged_in])
def create_peer(
db: Session = Depends(get_db),
name: str = Form(...),
expires_at: str = Form(""),
):
expiry = datetime.fromisoformat(expires_at) if expires_at else None
peer = service.create_peer(db, name.strip(), expiry)
return RedirectResponse(f"/peers/{peer.id}", status_code=303)
def _get_peer_or_404(db: Session, peer_id: int):
peer = service.get_peer(db, peer_id)
if peer is None:
raise HTTPException(status_code=404, detail="Peer not found")
return peer
@app.get("/peers/{peer_id}", response_class=HTMLResponse, dependencies=[logged_in])
def peer_detail(request: Request, peer_id: int, db: Session = Depends(get_db)):
peer = _get_peer_or_404(db, peer_id)
status = wireguard.get_status()
return templates.TemplateResponse(
request,
"peer_detail.html",
{"peer": peer, "peer_status": status.peers.get(peer.public_key)},
)
@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))
return RedirectResponse(f"/peers/{peer_id}", status_code=303)
@app.post("/peers/{peer_id}/rotate", dependencies=[logged_in])
def rotate_peer(peer_id: int, db: Session = Depends(get_db)):
service.rotate_peer_keys(db, _get_peer_or_404(db, peer_id))
return RedirectResponse(f"/peers/{peer_id}", status_code=303)
@app.post("/peers/{peer_id}/delete", dependencies=[logged_in])
def delete_peer(peer_id: int, db: Session = Depends(get_db)):
service.delete_peer(db, _get_peer_or_404(db, peer_id))
return RedirectResponse("/peers", 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)
return Response(
config,
media_type="text/plain",
headers={"Content-Disposition": f'attachment; filename="{peer.name}.conf"'},
)
@app.get("/peers/{peer_id}/qr", dependencies=[logged_in])
def peer_qr(peer_id: int, db: Session = Depends(get_db)):
peer = _get_peer_or_404(db, peer_id)
_, server_public = wireguard.ensure_server_keys()
config = wireguard.render_client_config(peer, server_public)
image = qrcode.make(config)
buffer = io.BytesIO()
image.save(buffer, format="PNG")
buffer.seek(0)
return StreamingResponse(buffer, media_type="image/png")
@app.get("/settings", response_class=HTMLResponse, dependencies=[logged_in])
def settings_page(request: Request):
_, server_public = wireguard.ensure_server_keys()
return templates.TemplateResponse(
request, "settings.html", {"settings": settings, "server_public": server_public}
)
@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": [
{
"id": peer.id,
"name": peer.name,
"address": peer.address,
"enabled": peer.enabled,
"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
],
}
+34
View File
@@ -0,0 +1,34 @@
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from .db import Base
def utcnow() -> datetime:
return datetime.now(timezone.utc)
class Peer(Base):
__tablename__ = "peers"
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)
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)
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)
class TrafficSample(Base):
__tablename__ = "traffic_samples"
id: Mapped[int] = mapped_column(Integer, primary_key=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)
sampled_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
+91
View File
@@ -0,0 +1,91 @@
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from . import wireguard
from .crypto import encrypt
from .models import Peer, TrafficSample
def list_peers(db: Session) -> list[Peer]:
return list(db.scalars(select(Peer).order_by(Peer.id)))
def get_peer(db: Session, peer_id: int) -> Peer | None:
return db.get(Peer, peer_id)
def create_peer(db: Session, name: str, expires_at: datetime | None = None) -> Peer:
private, public = wireguard.generate_keypair()
psk = wireguard.genpsk()
address = wireguard.next_free_address([p.address for p in list_peers(db)])
peer = Peer(
name=name,
public_key=public,
private_key_enc=encrypt(private),
preshared_key_enc=encrypt(psk),
address=address,
expires_at=expires_at,
)
db.add(peer)
db.commit()
apply_config(db)
return peer
def rotate_peer_keys(db: Session, peer: Peer) -> Peer:
private, public = wireguard.generate_keypair()
peer.public_key = public
peer.private_key_enc = encrypt(private)
peer.preshared_key_enc = encrypt(wireguard.genpsk())
db.commit()
apply_config(db)
return peer
def toggle_peer(db: Session, peer: Peer) -> Peer:
peer.enabled = not peer.enabled
db.commit()
apply_config(db)
return peer
def delete_peer(db: Session, peer: Peer) -> None:
db.delete(peer)
db.commit()
apply_config(db)
def disable_expired_peers(db: Session) -> bool:
now = datetime.now(timezone.utc)
changed = False
for peer in list_peers(db):
expires = peer.expires_at
if expires is not None and expires.tzinfo is None:
expires = expires.replace(tzinfo=timezone.utc)
if peer.enabled and expires is not None and expires <= now:
peer.enabled = False
changed = True
if changed:
db.commit()
apply_config(db)
return changed
def apply_config(db: Session) -> None:
private, _ = wireguard.ensure_server_keys()
wireguard.sync_peers(private, list_peers(db))
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,
)
)
db.commit()
+64
View File
@@ -0,0 +1,64 @@
const REFRESH_MS = 5000;
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 fmtHandshake(iso) {
if (!iso) return "never";
const seconds = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
if (seconds < 60) return seconds + "s ago";
if (seconds < 3600) return Math.floor(seconds / 60) + "m ago";
return Math.floor(seconds / 3600) + "h ago";
}
async function refresh() {
let data;
try {
const res = await fetch("/api/status");
if (!res.ok) return;
data = await res.json();
} catch {
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));
const tbody = document.querySelector("#peer-table tbody");
if (!tbody) return;
tbody.innerHTML = "";
for (const p of data.peers) {
const tr = document.createElement("tr");
const badge = p.online
? '<span class="badge ok">online</span>'
: p.enabled
? '<span class="badge muted">offline</span>'
: '<span class="badge err">disabled</span>';
tr.innerHTML =
`<td><a href="/peers/${p.id}"></a></td>` +
`<td>${p.address}</td>` +
`<td>${badge}</td>` +
`<td>${fmtHandshake(p.latest_handshake)}</td>` +
`<td>${fmtBytes(p.rx_bytes)}</td>` +
`<td>${fmtBytes(p.tx_bytes)}</td>`;
tr.querySelector("a").textContent = p.name;
tbody.appendChild(tr);
}
}
refresh();
setInterval(refresh, REFRESH_MS);
+88
View File
@@ -0,0 +1,88 @@
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, sans-serif;
background: #f4f5f7;
color: #1f2430;
}
nav {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1.5rem;
background: #1f2430;
color: #fff;
}
nav .brand { font-weight: 700; margin-right: 1rem; }
nav a { color: #cdd3e0; text-decoration: none; }
nav a:hover { color: #fff; }
nav form { margin-left: auto; }
main { max-width: 960px; margin: 0 auto; padding: 1.5rem; }
h1 { margin-top: 0; }
.cards { display: flex; gap: 1rem; flex-wrap: wrap; }
.card {
background: #fff;
border-radius: 8px;
padding: 1rem 1.25rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
flex: 1 1 220px;
}
.card h3 { margin: 0 0 0.5rem; font-size: 0.85rem; text-transform: uppercase; color: #6b7280; }
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; }
th, td { text-align: left; padding: 0.6rem 0.8rem; border-bottom: 1px solid #e5e7eb; }
thead th { background: #eef0f4; font-size: 0.8rem; text-transform: uppercase; color: #6b7280; }
table.kv { background: transparent; }
table.kv th { width: 200px; color: #6b7280; }
.badge { padding: 0.15rem 0.5rem; border-radius: 999px; font-size: 0.75rem; }
.badge.ok { background: #d1fae5; color: #065f46; }
.badge.err { background: #fee2e2; color: #991b1b; }
.badge.muted { background: #e5e7eb; color: #4b5563; }
.muted { color: #6b7280; }
.error { color: #991b1b; }
button, .button {
background: #2563eb;
color: #fff;
border: none;
border-radius: 6px;
padding: 0.45rem 0.9rem;
cursor: pointer;
font-size: 0.9rem;
text-decoration: none;
display: inline-block;
}
button:hover, .button:hover { background: #1d4ed8; }
button.danger { background: #dc2626; }
button.danger:hover { background: #b91c1c; }
button.link { background: none; padding: 0; color: #cdd3e0; }
button.link:hover { color: #fff; background: none; }
form.inline { display: inline; }
.actions form { margin-right: 0.25rem; }
.actions-row { margin-top: 1rem; display: flex; gap: 0.5rem; }
input {
padding: 0.45rem 0.6rem;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.9rem;
}
.add-peer { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; }
.login-page { display: flex; align-items: center; justify-content: center; min-height: 100vh; }
.login-box {
background: #fff;
padding: 2rem;
border-radius: 10px;
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
width: 320px;
}
.login-box h1 { font-size: 1.3rem; text-align: center; }
.login-box label { display: block; margin-bottom: 0.75rem; font-size: 0.9rem; }
.login-box input { width: 100%; margin-top: 0.25rem; }
.login-box button { width: 100%; margin-top: 0.5rem; }
.qr { max-width: 220px; display: block; margin-bottom: 0.5rem; }
code { word-break: break-all; font-size: 0.85em; }
+24
View File
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}WireGuard Admin{% endblock %}</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<nav>
<span class="brand">WireGuard Admin</span>
<a href="/">Dashboard</a>
<a href="/peers">Peers</a>
<a href="/settings">Settings</a>
<form method="post" action="/logout" class="inline">
<button type="submit" class="link">Logout</button>
</form>
</nav>
<main>
{% block content %}{% endblock %}
</main>
{% block scripts %}{% endblock %}
</body>
</html>
+34
View File
@@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block title %}Dashboard - WireGuard Admin{% endblock %}
{% block content %}
<h1>Dashboard</h1>
<div class="cards">
<div class="card">
<h3>Interface</h3>
<p>{{ status.name }}
{% if status.up %}<span class="badge ok">up</span>
{% else %}<span class="badge err">down</span>{% endif %}
</p>
<p class="muted">Port {{ status.listen_port or settings.wg_port }}</p>
</div>
<div class="card">
<h3>Peers</h3>
<p><span id="peer-count">{{ peers | length }}</span> total,
<span id="online-count">-</span> online</p>
</div>
<div class="card">
<h3>Traffic</h3>
<p>RX <span id="total-rx">{{ status.total_rx | fmt_bytes }}</span></p>
<p>TX <span id="total-tx">{{ status.total_tx | fmt_bytes }}</span></p>
</div>
</div>
<h2>Peers</h2>
<table id="peer-table">
<thead>
<tr><th>Name</th><th>Address</th><th>Status</th><th>Handshake</th><th>RX</th><th>TX</th></tr>
</thead>
<tbody></tbody>
</table>
{% endblock %}
{% block scripts %}<script src="/static/status.js"></script>{% endblock %}
+20
View File
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Login - WireGuard Admin</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="login-page">
<div class="login-box">
<h1>WireGuard Admin</h1>
{% if error %}<p class="error">{{ error }}</p>{% endif %}
<form method="post" action="/login">
<label>Username <input name="username" autofocus required></label>
<label>Password <input name="password" type="password" required></label>
<button type="submit">Sign in</button>
</form>
</div>
</body>
</html>
+48
View File
@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block title %}{{ peer.name }} - WireGuard Admin{% endblock %}
{% block content %}
<h1>{{ peer.name }}</h1>
<div class="cards">
<div class="card">
<h3>Details</h3>
<p>Address: {{ peer.address }}</p>
<p>Public key: <code>{{ peer.public_key }}</code></p>
<p>Enabled:
{% if peer.enabled %}<span class="badge ok">yes</span>
{% else %}<span class="badge err">no</span>{% endif %}
</p>
<p class="muted">Created {{ peer.created_at.strftime("%Y-%m-%d %H:%M") }}</p>
{% if peer.expires_at %}<p class="muted">Expires {{ peer.expires_at.strftime("%Y-%m-%d %H:%M") }}</p>{% endif %}
</div>
<div class="card">
<h3>Live status</h3>
{% if peer_status %}
<p>Status: {% if peer_status.online %}<span class="badge ok">online</span>{% else %}<span class="badge muted">offline</span>{% endif %}</p>
<p>Endpoint: {{ peer_status.endpoint or "-" }}</p>
<p>Handshake: {{ peer_status.latest_handshake.strftime("%Y-%m-%d %H:%M:%S") if peer_status.latest_handshake else "never" }}</p>
<p>RX {{ peer_status.rx_bytes | fmt_bytes }} / TX {{ peer_status.tx_bytes | fmt_bytes }}</p>
{% else %}
<p class="muted">Not present on interface (disabled or interface down).</p>
{% endif %}
</div>
<div class="card">
<h3>Client config</h3>
<img src="/peers/{{ peer.id }}/qr" alt="QR code" class="qr">
<p><a href="/peers/{{ peer.id }}/config" class="button">Download .conf</a></p>
</div>
</div>
<div class="actions-row">
<form method="post" action="/peers/{{ peer.id }}/toggle" class="inline">
<button type="submit">{{ "Disable" if peer.enabled else "Enable" }}</button>
</form>
<form method="post" action="/peers/{{ peer.id }}/rotate" class="inline"
onsubmit="return confirm('Rotate keys? The old client config stops working immediately.')">
<button type="submit">Rotate keys</button>
</form>
<form method="post" action="/peers/{{ peer.id }}/delete" class="inline"
onsubmit="return confirm('Delete peer {{ peer.name }}?')">
<button type="submit" class="danger">Delete</button>
</form>
</div>
{% endblock %}
+40
View File
@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block title %}Peers - WireGuard Admin{% endblock %}
{% block content %}
<h1>Peers</h1>
<form method="post" action="/peers" class="add-peer">
<input name="name" placeholder="Peer name" required>
<label class="muted">Expires <input name="expires_at" type="datetime-local"></label>
<button type="submit">Add peer</button>
</form>
<table>
<thead>
<tr><th>Name</th><th>Address</th><th>Enabled</th><th>Status</th><th>Created</th><th></th></tr>
</thead>
<tbody>
{% for peer in peers %}
{% set ps = status.peers.get(peer.public_key) %}
<tr>
<td><a href="/peers/{{ peer.id }}">{{ peer.name }}</a></td>
<td>{{ peer.address }}</td>
<td>{% if peer.enabled %}<span class="badge ok">enabled</span>{% else %}<span class="badge err">disabled</span>{% endif %}</td>
<td>{% if ps and ps.online %}<span class="badge ok">online</span>{% else %}<span class="badge muted">offline</span>{% endif %}</td>
<td class="muted">{{ peer.created_at.strftime("%Y-%m-%d") }}</td>
<td class="actions">
<form method="post" action="/peers/{{ peer.id }}/toggle" class="inline">
<button type="submit">{{ "Disable" if peer.enabled else "Enable" }}</button>
</form>
<form method="post" action="/peers/{{ peer.id }}/delete" class="inline"
onsubmit="return confirm('Delete peer {{ peer.name }}?')">
<button type="submit" class="danger">Delete</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="6" class="muted">No peers yet. Add one above.</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
+18
View File
@@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block title %}Settings - WireGuard Admin{% endblock %}
{% block content %}
<h1>Settings</h1>
<div class="card">
<table class="kv">
<tr><th>Interface</th><td>{{ settings.wg_interface }}</td></tr>
<tr><th>Server public key</th><td><code>{{ server_public }}</code></td></tr>
<tr><th>Endpoint host</th><td>{{ settings.wg_host }}</td></tr>
<tr><th>Listen port</th><td>{{ settings.wg_port }}</td></tr>
<tr><th>Subnet</th><td>{{ settings.wg_subnet }}</td></tr>
<tr><th>Client DNS</th><td>{{ settings.wg_dns }}</td></tr>
<tr><th>Client AllowedIPs</th><td>{{ settings.wg_allowed_ips }}</td></tr>
<tr><th>Keepalive</th><td>{{ settings.wg_persistent_keepalive }}s</td></tr>
</table>
<p class="muted">Settings are read from environment variables / .env and require a restart to change.</p>
</div>
{% endblock %}
+190
View File
@@ -0,0 +1,190 @@
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 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 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}",
]
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 = {peer.address}",
]
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 = {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 = {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 interface_up() -> None:
if not get_status().up:
_run(["wg-quick", "up", settings.wg_interface])
def sync_peers(private_key: str, peers: list[Peer]) -> None:
write_server_config(private_key, peers)
if get_status().up:
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)