Add custom peer IP/DNS, inline config view with copy, breadcrumbs, form validation errors

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-07-04 07:43:26 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent c4dc22074e
commit 628246c5c5
11 changed files with 95 additions and 13 deletions
+1
View File
@@ -25,6 +25,7 @@ def get_db():
PEER_MIGRATIONS = {
"note": "ALTER TABLE peers ADD COLUMN note VARCHAR(256) NOT NULL DEFAULT ''",
"dns": "ALTER TABLE peers ADD COLUMN dns VARCHAR(128) NOT NULL DEFAULT ''",
"quota_bytes": "ALTER TABLE peers ADD COLUMN quota_bytes INTEGER NOT NULL DEFAULT 0",
"cum_rx": "ALTER TABLE peers ADD COLUMN cum_rx INTEGER NOT NULL DEFAULT 0",
"cum_tx": "ALTER TABLE peers ADD COLUMN cum_tx INTEGER NOT NULL DEFAULT 0",
+27 -8
View File
@@ -2,6 +2,7 @@ import asyncio
import io
from contextlib import asynccontextmanager
from datetime import datetime
from urllib.parse import quote
import qrcode
from fastapi import Depends, FastAPI, Form, HTTPException, Request
@@ -108,11 +109,13 @@ def dashboard(request: Request, db: Session = Depends(get_db)):
@app.get("/peers", response_class=HTMLResponse, dependencies=[logged_in])
def peers_page(request: Request, db: Session = Depends(get_db)):
def peers_page(request: Request, db: Session = Depends(get_db), error: str = ""):
status = wireguard.get_status()
peers = service.list_peers(db)
return templates.TemplateResponse(
request, "peers.html", {"peers": peers, "status": status}
request,
"peers.html",
{"peers": peers, "status": status, "error": error, "settings": settings},
)
@@ -129,13 +132,22 @@ def create_peer(
note: str = Form(""),
quota_gib: str = Form(""),
count: int = Form(1),
address: str = Form(""),
dns: str = Form(""),
):
expiry = datetime.fromisoformat(expires_at) if expires_at else None
quota = _parse_quota_gib(quota_gib)
if count > 1:
service.create_peers_batch(db, name.strip(), min(count, 50), expiry, note.strip(), quota)
return RedirectResponse("/peers", status_code=303)
peer = service.create_peer(db, name.strip(), expiry, note.strip(), quota)
try:
if count > 1:
service.create_peers_batch(
db, name.strip(), min(count, 50), expiry, note.strip(), quota
)
return RedirectResponse("/peers", status_code=303)
peer = service.create_peer(
db, name.strip(), expiry, note.strip(), quota, address.strip(), dns.strip()
)
except ValueError as exc:
return RedirectResponse(f"/peers?error={quote(str(exc))}", status_code=303)
return RedirectResponse(f"/peers/{peer.id}", status_code=303)
@@ -150,10 +162,16 @@ def _get_peer_or_404(db: Session, peer_id: int):
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()
_, server_public = wireguard.ensure_server_keys()
client_config = wireguard.render_client_config(peer, server_public)
return templates.TemplateResponse(
request,
"peer_detail.html",
{"peer": peer, "peer_status": status.peers.get(peer.public_key)},
{
"peer": peer,
"peer_status": status.peers.get(peer.public_key),
"client_config": client_config,
},
)
@@ -163,9 +181,10 @@ def update_peer(
db: Session = Depends(get_db),
note: str = Form(""),
quota_gib: str = Form(""),
dns: str = Form(""),
):
peer = _get_peer_or_404(db, peer_id)
service.update_peer(db, peer, note.strip(), _parse_quota_gib(quota_gib))
service.update_peer(db, peer, note.strip(), _parse_quota_gib(quota_gib), dns.strip())
return RedirectResponse(f"/peers/{peer_id}", status_code=303)
+1
View File
@@ -23,6 +23,7 @@ class Peer(Base):
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
note: Mapped[str] = mapped_column(String(256), default="")
dns: Mapped[str] = mapped_column(String(128), default="")
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)
+10 -2
View File
@@ -22,10 +22,16 @@ def create_peer(
expires_at: datetime | None = None,
note: str = "",
quota_bytes: int = 0,
address: str = "",
dns: str = "",
) -> Peer:
private, public = wireguard.generate_keypair()
psk = wireguard.genpsk()
address = wireguard.next_free_address([p.address for p in list_peers(db)])
taken = [p.address for p in list_peers(db)]
if address:
address = wireguard.validate_address(address, taken)
else:
address = wireguard.next_free_address(taken)
peer = Peer(
name=name,
public_key=public,
@@ -35,6 +41,7 @@ def create_peer(
expires_at=expires_at,
note=note,
quota_bytes=quota_bytes,
dns=dns,
)
db.add(peer)
db.commit()
@@ -62,9 +69,10 @@ def create_peers_batch(
return peers
def update_peer(db: Session, peer: Peer, note: str, quota_bytes: int) -> Peer:
def update_peer(db: Session, peer: Peer, note: str, quota_bytes: int, dns: str = "") -> Peer:
peer.note = note
peer.quota_bytes = quota_bytes
peer.dns = dns
db.commit()
if not peer.over_quota:
apply_config(db)
+13
View File
@@ -71,6 +71,19 @@ input {
}
.add-peer { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.25rem; flex-wrap: wrap; }
input.narrow { width: 80px; }
input.mid { width: 160px; }
.breadcrumbs { margin-bottom: 1rem; font-size: 0.85rem; color: #6b7280; }
.breadcrumbs a { color: #2563eb; text-decoration: none; }
.breadcrumbs a:hover { text-decoration: underline; }
.config-card { margin-top: 1rem; }
.config-card pre {
background: #1f2430;
color: #d6e2ff;
padding: 1rem;
border-radius: 6px;
overflow-x: auto;
font-size: 0.85rem;
}
form.stack label { display: block; margin-bottom: 0.5rem; }
form.stack { margin-top: 0.75rem; }
+4
View File
@@ -17,6 +17,10 @@
</form>
</nav>
<main>
<div class="breadcrumbs">
<a href="/">Home</a>
{% block breadcrumbs %}{% endblock %}
</div>
{% block content %}{% endblock %}
</main>
{% block scripts %}{% endblock %}
+1
View File
@@ -1,5 +1,6 @@
{% extends "base.html" %}
{% block title %}Dashboard - WireGuard Admin{% endblock %}
{% block breadcrumbs %}<span>/</span> <span>Dashboard</span>{% endblock %}
{% block content %}
<h1>Dashboard</h1>
<div class="cards">
+14 -1
View File
@@ -1,5 +1,6 @@
{% extends "base.html" %}
{% block title %}{{ peer.name }} - WireGuard Admin{% endblock %}
{% block breadcrumbs %}<span>/</span> <a href="/peers">Peers</a> <span>/</span> <span>{{ peer.name }}</span>{% endblock %}
{% block content %}
<h1>{{ peer.name }}</h1>
<div class="cards">
@@ -20,6 +21,7 @@
</p>
<form method="post" action="/peers/{{ peer.id }}/update" class="stack">
<label class="muted">Note <input name="note" value="{{ peer.note }}"></label>
<label class="muted">DNS <input name="dns" value="{{ peer.dns }}" placeholder="server default"></label>
<label class="muted">Quota GiB
<input name="quota_gib" type="number" step="0.1" min="0" class="narrow"
value="{{ '%.1f' % (peer.quota_bytes / 1073741824) if peer.quota_bytes else '' }}" placeholder="0 = unlimited">
@@ -41,10 +43,21 @@
<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>
<p><a href="/peers/{{ peer.id }}/config" class="button">Download .conf</a>
<button type="button" onclick="copyConfig()">Copy</button></p>
</div>
</div>
<div class="card config-card">
<h3>{{ peer.name }}.conf</h3>
<pre id="client-config">{{ client_config }}</pre>
</div>
<script>
function copyConfig() {
navigator.clipboard.writeText(document.getElementById("client-config").textContent);
}
</script>
<div class="actions-row">
<form method="post" action="/peers/{{ peer.id }}/toggle" class="inline">
<button type="submit">{{ "Disable" if peer.enabled else "Enable" }}</button>
+5 -1
View File
@@ -1,17 +1,21 @@
{% extends "base.html" %}
{% block title %}Peers - WireGuard Admin{% endblock %}
{% block breadcrumbs %}<span>/</span> <span>Peers</span>{% endblock %}
{% block content %}
<h1>Peers</h1>
{% if error %}<p class="error">{{ error }}</p>{% endif %}
<form method="post" action="/peers" class="add-peer">
<input name="name" placeholder="Peer name" required>
<input name="note" placeholder="Note (optional)">
<input name="address" placeholder="IP (auto if empty)" class="mid">
<input name="dns" placeholder="DNS (default {{ settings.wg_dns }})" class="mid">
<label class="muted">Quota GiB <input name="quota_gib" type="number" step="0.1" min="0" class="narrow" placeholder="0"></label>
<label class="muted">Count <input name="count" type="number" min="1" max="50" value="1" class="narrow"></label>
<label class="muted">Expires <input name="expires_at" type="datetime-local"></label>
<button type="submit">Add peer(s)</button>
</form>
<p class="muted">Count &gt; 1 creates a batch named name-1, name-2, ... Quota 0 = unlimited.</p>
<p class="muted">Count &gt; 1 creates a batch named name-1, name-2, ... (custom IP ignored). Quota 0 = unlimited.</p>
<table>
<thead>
+1
View File
@@ -1,5 +1,6 @@
{% extends "base.html" %}
{% block title %}Settings - WireGuard Admin{% endblock %}
{% block breadcrumbs %}<span>/</span> <span>Settings</span>{% endblock %}
{% block content %}
<h1>Settings</h1>
<div class="card">
+18 -1
View File
@@ -119,6 +119,23 @@ def server_address() -> str:
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}
@@ -157,7 +174,7 @@ def render_client_config(peer: Peer, server_public_key: str) -> str:
"[Interface]",
f"PrivateKey = {decrypt(peer.private_key_enc)}",
f"Address = {peer.address}",
f"DNS = {settings.wg_dns}",
f"DNS = {peer.dns or settings.wg_dns}",
"",
"[Peer]",
f"PublicKey = {server_public_key}",