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 = {
|
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
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|
||||||
|
|||||||
@@ -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,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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 > 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>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
@@ -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
@@ -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}",
|
||||||
|
|||||||
Reference in New Issue
Block a user