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 = { PEER_MIGRATIONS = {
"note": "ALTER TABLE peers ADD COLUMN note VARCHAR(256) NOT NULL DEFAULT ''", "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", "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_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", "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 import io
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime from datetime import datetime
from urllib.parse import quote
import qrcode import qrcode
from fastapi import Depends, FastAPI, Form, HTTPException, Request 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]) @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() status = wireguard.get_status()
peers = service.list_peers(db) peers = service.list_peers(db)
return templates.TemplateResponse( 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(""), note: str = Form(""),
quota_gib: str = Form(""), quota_gib: str = Form(""),
count: int = Form(1), count: int = Form(1),
address: str = Form(""),
dns: str = Form(""),
): ):
expiry = datetime.fromisoformat(expires_at) if expires_at else None expiry = datetime.fromisoformat(expires_at) if expires_at else None
quota = _parse_quota_gib(quota_gib) quota = _parse_quota_gib(quota_gib)
if count > 1: try:
service.create_peers_batch(db, name.strip(), min(count, 50), expiry, note.strip(), quota) if count > 1:
return RedirectResponse("/peers", status_code=303) service.create_peers_batch(
peer = service.create_peer(db, name.strip(), expiry, note.strip(), quota) 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) 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)): def peer_detail(request: Request, peer_id: int, db: Session = Depends(get_db)):
peer = _get_peer_or_404(db, peer_id) peer = _get_peer_or_404(db, peer_id)
status = wireguard.get_status() status = wireguard.get_status()
_, server_public = wireguard.ensure_server_keys()
client_config = wireguard.render_client_config(peer, server_public)
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"peer_detail.html", "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), db: Session = Depends(get_db),
note: str = Form(""), note: str = Form(""),
quota_gib: str = Form(""), quota_gib: str = Form(""),
dns: str = Form(""),
): ):
peer = _get_peer_or_404(db, peer_id) 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) 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) expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
note: Mapped[str] = mapped_column(String(256), default="") 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) quota_bytes: Mapped[int] = mapped_column(Integer, default=0)
cum_rx: Mapped[int] = mapped_column(Integer, default=0) cum_rx: Mapped[int] = mapped_column(Integer, default=0)
cum_tx: Mapped[int] = mapped_column(Integer, default=0) cum_tx: Mapped[int] = mapped_column(Integer, default=0)
+10 -2
View File
@@ -22,10 +22,16 @@ def create_peer(
expires_at: datetime | None = None, expires_at: datetime | None = None,
note: str = "", note: str = "",
quota_bytes: int = 0, quota_bytes: int = 0,
address: str = "",
dns: str = "",
) -> Peer: ) -> Peer:
private, public = wireguard.generate_keypair() private, public = wireguard.generate_keypair()
psk = wireguard.genpsk() 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( peer = Peer(
name=name, name=name,
public_key=public, public_key=public,
@@ -35,6 +41,7 @@ def create_peer(
expires_at=expires_at, expires_at=expires_at,
note=note, note=note,
quota_bytes=quota_bytes, quota_bytes=quota_bytes,
dns=dns,
) )
db.add(peer) db.add(peer)
db.commit() db.commit()
@@ -62,9 +69,10 @@ def create_peers_batch(
return peers 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.note = note
peer.quota_bytes = quota_bytes peer.quota_bytes = quota_bytes
peer.dns = dns
db.commit() db.commit()
if not peer.over_quota: if not peer.over_quota:
apply_config(db) 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; } .add-peer { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.25rem; flex-wrap: wrap; }
input.narrow { width: 80px; } 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 label { display: block; margin-bottom: 0.5rem; }
form.stack { margin-top: 0.75rem; } form.stack { margin-top: 0.75rem; }
+4
View File
@@ -17,6 +17,10 @@
</form> </form>
</nav> </nav>
<main> <main>
<div class="breadcrumbs">
<a href="/">Home</a>
{% block breadcrumbs %}{% endblock %}
</div>
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
+1
View File
@@ -1,5 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Dashboard - WireGuard Admin{% endblock %} {% block title %}Dashboard - WireGuard Admin{% endblock %}
{% block breadcrumbs %}<span>/</span> <span>Dashboard</span>{% endblock %}
{% block content %} {% block content %}
<h1>Dashboard</h1> <h1>Dashboard</h1>
<div class="cards"> <div class="cards">
+14 -1
View File
@@ -1,5 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ peer.name }} - WireGuard Admin{% endblock %} {% 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 %} {% block content %}
<h1>{{ peer.name }}</h1> <h1>{{ peer.name }}</h1>
<div class="cards"> <div class="cards">
@@ -20,6 +21,7 @@
</p> </p>
<form method="post" action="/peers/{{ peer.id }}/update" class="stack"> <form method="post" action="/peers/{{ peer.id }}/update" class="stack">
<label class="muted">Note <input name="note" value="{{ peer.note }}"></label> <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 <label class="muted">Quota GiB
<input name="quota_gib" type="number" step="0.1" min="0" class="narrow" <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"> value="{{ '%.1f' % (peer.quota_bytes / 1073741824) if peer.quota_bytes else '' }}" placeholder="0 = unlimited">
@@ -41,10 +43,21 @@
<div class="card"> <div class="card">
<h3>Client config</h3> <h3>Client config</h3>
<img src="/peers/{{ peer.id }}/qr" alt="QR code" class="qr"> <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> </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"> <div class="actions-row">
<form method="post" action="/peers/{{ peer.id }}/toggle" class="inline"> <form method="post" action="/peers/{{ peer.id }}/toggle" class="inline">
<button type="submit">{{ "Disable" if peer.enabled else "Enable" }}</button> <button type="submit">{{ "Disable" if peer.enabled else "Enable" }}</button>
+5 -1
View File
@@ -1,17 +1,21 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Peers - WireGuard Admin{% endblock %} {% block title %}Peers - WireGuard Admin{% endblock %}
{% block breadcrumbs %}<span>/</span> <span>Peers</span>{% endblock %}
{% block content %} {% block content %}
<h1>Peers</h1> <h1>Peers</h1>
{% if error %}<p class="error">{{ error }}</p>{% endif %}
<form method="post" action="/peers" class="add-peer"> <form method="post" action="/peers" class="add-peer">
<input name="name" placeholder="Peer name" required> <input name="name" placeholder="Peer name" required>
<input name="note" placeholder="Note (optional)"> <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">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">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> <label class="muted">Expires <input name="expires_at" type="datetime-local"></label>
<button type="submit">Add peer(s)</button> <button type="submit">Add peer(s)</button>
</form> </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> <table>
<thead> <thead>
+1
View File
@@ -1,5 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Settings - WireGuard Admin{% endblock %} {% block title %}Settings - WireGuard Admin{% endblock %}
{% block breadcrumbs %}<span>/</span> <span>Settings</span>{% endblock %}
{% block content %} {% block content %}
<h1>Settings</h1> <h1>Settings</h1>
<div class="card"> <div class="card">
+18 -1
View File
@@ -119,6 +119,23 @@ def server_address() -> str:
return f"{next(network.hosts())}/{network.prefixlen}" 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: def next_free_address(taken: list[str]) -> str:
network = ipaddress.ip_network(settings.wg_subnet) network = ipaddress.ip_network(settings.wg_subnet)
used = {ipaddress.ip_interface(a).ip for a in taken} 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]", "[Interface]",
f"PrivateKey = {decrypt(peer.private_key_enc)}", f"PrivateKey = {decrypt(peer.private_key_enc)}",
f"Address = {peer.address}", f"Address = {peer.address}",
f"DNS = {settings.wg_dns}", f"DNS = {peer.dns or settings.wg_dns}",
"", "",
"[Peer]", "[Peer]",
f"PublicKey = {server_public_key}", f"PublicKey = {server_public_key}",