diff --git a/app/db.py b/app/db.py index c1879ce..896ef86 100644 --- a/app/db.py +++ b/app/db.py @@ -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", diff --git a/app/main.py b/app/main.py index 6fd8fd9..c14d596 100644 --- a/app/main.py +++ b/app/main.py @@ -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) diff --git a/app/models.py b/app/models.py index 0a61d37..4933abf 100644 --- a/app/models.py +++ b/app/models.py @@ -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) diff --git a/app/service.py b/app/service.py index fcab3b8..a82a8d1 100644 --- a/app/service.py +++ b/app/service.py @@ -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) diff --git a/app/static/style.css b/app/static/style.css index fad9a30..c469266 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -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; } diff --git a/app/templates/base.html b/app/templates/base.html index 9ff4e81..916c6e5 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -17,6 +17,10 @@
+ {% block content %}{% endblock %}
{% block scripts %}{% endblock %} diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index e8b80c1..efa588f 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -1,5 +1,6 @@ {% extends "base.html" %} {% block title %}Dashboard - WireGuard Admin{% endblock %} +{% block breadcrumbs %}/ Dashboard{% endblock %} {% block content %}

Dashboard

diff --git a/app/templates/peer_detail.html b/app/templates/peer_detail.html index 9536007..be2f107 100644 --- a/app/templates/peer_detail.html +++ b/app/templates/peer_detail.html @@ -1,5 +1,6 @@ {% extends "base.html" %} {% block title %}{{ peer.name }} - WireGuard Admin{% endblock %} +{% block breadcrumbs %}/ Peers / {{ peer.name }}{% endblock %} {% block content %}

{{ peer.name }}

@@ -20,6 +21,7 @@

+
+
+

{{ peer.name }}.conf

+
{{ client_config }}
+
+ +
diff --git a/app/templates/peers.html b/app/templates/peers.html index e0a8f8f..9caf4df 100644 --- a/app/templates/peers.html +++ b/app/templates/peers.html @@ -1,17 +1,21 @@ {% extends "base.html" %} {% block title %}Peers - WireGuard Admin{% endblock %} +{% block breadcrumbs %}/ Peers{% endblock %} {% block content %}

Peers

+{% if error %}

{{ error }}

{% endif %} + + -

Count > 1 creates a batch named name-1, name-2, ... Quota 0 = unlimited.

+

Count > 1 creates a batch named name-1, name-2, ... (custom IP ignored). Quota 0 = unlimited.

diff --git a/app/templates/settings.html b/app/templates/settings.html index bfdf76e..9e0c576 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -1,5 +1,6 @@ {% extends "base.html" %} {% block title %}Settings - WireGuard Admin{% endblock %} +{% block breadcrumbs %}/Settings{% endblock %} {% block content %}

Settings

diff --git a/app/wireguard.py b/app/wireguard.py index bb4594a..988e482 100644 --- a/app/wireguard.py +++ b/app/wireguard.py @@ -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}",