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:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent
c4dc22074e
commit
628246c5c5
@@ -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
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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,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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 > 1 creates a batch named name-1, name-2, ... Quota 0 = unlimited.</p>
|
||||
<p class="muted">Count > 1 creates a batch named name-1, name-2, ... (custom IP ignored). Quota 0 = unlimited.</p>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
|
||||
@@ -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
@@ -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}",
|
||||
|
||||
Reference in New Issue
Block a user