commit 998dfe702c6b2ffa594c8bcf67a4a66e886eac01 Author: lofyer Date: Fri Jul 3 22:23:14 2026 +0800 Initial WireGuard admin panel: FastAPI UI, peer key management, live monitoring, Docker deploy Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f8f9839 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.venv/ +__pycache__/ +*.pyc +data/ +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..43da9ed --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.13-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends wireguard-tools iptables iproute2 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /opt/wireguard-admin + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app +COPY entrypoint.sh . +RUN chmod +x entrypoint.sh + +EXPOSE 8000/tcp 51820/udp + +ENTRYPOINT ["./entrypoint.sh"] diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ + diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..93046f4 --- /dev/null +++ b/app/auth.py @@ -0,0 +1,40 @@ +import bcrypt +from fastapi import Depends, HTTPException, Request, status +from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer + +from .config import settings + +SESSION_COOKIE = "wg_admin_session" + +_serializer = URLSafeTimedSerializer(settings.secret_key, salt="wg-admin-session") +_password_hash = bcrypt.hashpw(settings.admin_password.encode(), bcrypt.gensalt()) + + +def verify_credentials(username: str, password: str) -> bool: + if username != settings.admin_username: + return False + return bcrypt.checkpw(password.encode(), _password_hash) + + +def create_session_token() -> str: + return _serializer.dumps({"user": settings.admin_username}) + + +def validate_session_token(token: str) -> bool: + try: + _serializer.loads(token, max_age=settings.session_max_age) + return True + except (BadSignature, SignatureExpired): + return False + + +def require_login(request: Request) -> None: + token = request.cookies.get(SESSION_COOKIE) + if not token or not validate_session_token(token): + raise HTTPException( + status_code=status.HTTP_303_SEE_OTHER, + headers={"Location": "/login"}, + ) + + +logged_in = Depends(require_login) diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..701bdc1 --- /dev/null +++ b/app/config.py @@ -0,0 +1,27 @@ +from pathlib import Path + +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + wg_interface: str = "wg0" + wg_host: str = "127.0.0.1" + wg_port: int = 51820 + wg_subnet: str = "10.8.0.0/24" + wg_dns: str = "1.1.1.1" + wg_allowed_ips: str = "0.0.0.0/0, ::/0" + wg_persistent_keepalive: int = 25 + wg_config_dir: Path = Path("/etc/wireguard") + + admin_username: str = "admin" + admin_password: str = "changeme" + secret_key: str = "change-this-secret-key" + session_max_age: int = 12 * 3600 + + data_dir: Path = Path("data") + + model_config = {"env_file": ".env"} + + +settings = Settings() +settings.data_dir.mkdir(parents=True, exist_ok=True) diff --git a/app/crypto.py b/app/crypto.py new file mode 100644 index 0000000..f3f23ba --- /dev/null +++ b/app/crypto.py @@ -0,0 +1,19 @@ +import base64 +import hashlib + +from cryptography.fernet import Fernet + +from .config import settings + + +def _fernet() -> Fernet: + digest = hashlib.sha256(settings.secret_key.encode()).digest() + return Fernet(base64.urlsafe_b64encode(digest)) + + +def encrypt(value: str) -> str: + return _fernet().encrypt(value.encode()).decode() + + +def decrypt(value: str) -> str: + return _fernet().decrypt(value.encode()).decode() diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..029ef68 --- /dev/null +++ b/app/db.py @@ -0,0 +1,23 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker + +from .config import settings + + +class Base(DeclarativeBase): + pass + + +engine = create_engine( + f"sqlite:///{settings.data_dir / 'wireguard.db'}", + connect_args={"check_same_thread": False}, +) +SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) + + +def get_db(): + db: Session = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..51aa557 --- /dev/null +++ b/app/main.py @@ -0,0 +1,224 @@ +import asyncio +import io +from contextlib import asynccontextmanager +from datetime import datetime + +import qrcode +from fastapi import Depends, FastAPI, Form, HTTPException, Request +from fastapi.responses import HTMLResponse, RedirectResponse, Response, StreamingResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from sqlalchemy.orm import Session + +from . import service, wireguard +from .auth import SESSION_COOKIE, create_session_token, logged_in, verify_credentials +from .config import settings +from .db import Base, SessionLocal, engine, get_db + +TRAFFIC_SAMPLE_INTERVAL = 300 + +templates = Jinja2Templates(directory="app/templates") + + +async def _background_sampler() -> None: + while True: + await asyncio.sleep(TRAFFIC_SAMPLE_INTERVAL) + db = SessionLocal() + try: + service.disable_expired_peers(db) + service.sample_traffic(db) + except Exception: + pass + finally: + db.close() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + Base.metadata.create_all(engine) + db = SessionLocal() + try: + service.apply_config(db) + try: + wireguard.interface_up() + except Exception: + pass + finally: + db.close() + task = asyncio.create_task(_background_sampler()) + yield + task.cancel() + + +app = FastAPI(title="WireGuard Admin", lifespan=lifespan) +app.mount("/static", StaticFiles(directory="app/static"), name="static") + + +def _fmt_bytes(num: float) -> str: + for unit in ("B", "KiB", "MiB", "GiB", "TiB"): + if abs(num) < 1024: + return f"{num:.1f} {unit}" + num /= 1024 + return f"{num:.1f} PiB" + + +templates.env.filters["fmt_bytes"] = _fmt_bytes + + +@app.get("/login", response_class=HTMLResponse) +def login_page(request: Request): + return templates.TemplateResponse(request, "login.html", {"error": None}) + + +@app.post("/login") +def login(request: Request, username: str = Form(...), password: str = Form(...)): + if not verify_credentials(username, password): + return templates.TemplateResponse( + request, "login.html", {"error": "Invalid username or password"}, status_code=401 + ) + response = RedirectResponse("/", status_code=303) + response.set_cookie( + SESSION_COOKIE, + create_session_token(), + max_age=settings.session_max_age, + httponly=True, + samesite="lax", + ) + return response + + +@app.post("/logout") +def logout(): + response = RedirectResponse("/login", status_code=303) + response.delete_cookie(SESSION_COOKIE) + return response + + +@app.get("/", response_class=HTMLResponse, dependencies=[logged_in]) +def dashboard(request: Request, db: Session = Depends(get_db)): + status = wireguard.get_status() + peers = service.list_peers(db) + return templates.TemplateResponse( + request, + "dashboard.html", + {"status": status, "peers": peers, "settings": settings}, + ) + + +@app.get("/peers", response_class=HTMLResponse, dependencies=[logged_in]) +def peers_page(request: Request, db: Session = Depends(get_db)): + status = wireguard.get_status() + peers = service.list_peers(db) + return templates.TemplateResponse( + request, "peers.html", {"peers": peers, "status": status} + ) + + +@app.post("/peers", dependencies=[logged_in]) +def create_peer( + db: Session = Depends(get_db), + name: str = Form(...), + expires_at: str = Form(""), +): + expiry = datetime.fromisoformat(expires_at) if expires_at else None + peer = service.create_peer(db, name.strip(), expiry) + return RedirectResponse(f"/peers/{peer.id}", status_code=303) + + +def _get_peer_or_404(db: Session, peer_id: int): + peer = service.get_peer(db, peer_id) + if peer is None: + raise HTTPException(status_code=404, detail="Peer not found") + return peer + + +@app.get("/peers/{peer_id}", response_class=HTMLResponse, dependencies=[logged_in]) +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() + return templates.TemplateResponse( + request, + "peer_detail.html", + {"peer": peer, "peer_status": status.peers.get(peer.public_key)}, + ) + + +@app.post("/peers/{peer_id}/toggle", dependencies=[logged_in]) +def toggle_peer(peer_id: int, db: Session = Depends(get_db)): + service.toggle_peer(db, _get_peer_or_404(db, peer_id)) + return RedirectResponse(f"/peers/{peer_id}", status_code=303) + + +@app.post("/peers/{peer_id}/rotate", dependencies=[logged_in]) +def rotate_peer(peer_id: int, db: Session = Depends(get_db)): + service.rotate_peer_keys(db, _get_peer_or_404(db, peer_id)) + return RedirectResponse(f"/peers/{peer_id}", status_code=303) + + +@app.post("/peers/{peer_id}/delete", dependencies=[logged_in]) +def delete_peer(peer_id: int, db: Session = Depends(get_db)): + service.delete_peer(db, _get_peer_or_404(db, peer_id)) + return RedirectResponse("/peers", status_code=303) + + +@app.get("/peers/{peer_id}/config", dependencies=[logged_in]) +def peer_config(peer_id: int, db: Session = Depends(get_db)): + peer = _get_peer_or_404(db, peer_id) + _, server_public = wireguard.ensure_server_keys() + config = wireguard.render_client_config(peer, server_public) + return Response( + config, + media_type="text/plain", + headers={"Content-Disposition": f'attachment; filename="{peer.name}.conf"'}, + ) + + +@app.get("/peers/{peer_id}/qr", dependencies=[logged_in]) +def peer_qr(peer_id: int, db: Session = Depends(get_db)): + peer = _get_peer_or_404(db, peer_id) + _, server_public = wireguard.ensure_server_keys() + config = wireguard.render_client_config(peer, server_public) + image = qrcode.make(config) + buffer = io.BytesIO() + image.save(buffer, format="PNG") + buffer.seek(0) + return StreamingResponse(buffer, media_type="image/png") + + +@app.get("/settings", response_class=HTMLResponse, dependencies=[logged_in]) +def settings_page(request: Request): + _, server_public = wireguard.ensure_server_keys() + return templates.TemplateResponse( + request, "settings.html", {"settings": settings, "server_public": server_public} + ) + + +@app.get("/api/status", dependencies=[logged_in]) +def api_status(db: Session = Depends(get_db)): + status = wireguard.get_status() + peers = service.list_peers(db) + return { + "interface": { + "name": status.name, + "up": status.up, + "listen_port": status.listen_port, + "total_rx": status.total_rx, + "total_tx": status.total_tx, + }, + "peers": [ + { + "id": peer.id, + "name": peer.name, + "address": peer.address, + "enabled": peer.enabled, + "online": (ps := status.peers.get(peer.public_key)) is not None and ps.online, + "endpoint": ps.endpoint if ps else None, + "latest_handshake": ps.latest_handshake.isoformat() + if ps and ps.latest_handshake + else None, + "rx_bytes": ps.rx_bytes if ps else 0, + "tx_bytes": ps.tx_bytes if ps else 0, + } + for peer in peers + ], + } diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..ed83edc --- /dev/null +++ b/app/models.py @@ -0,0 +1,34 @@ +from datetime import datetime, timezone + +from sqlalchemy import Boolean, DateTime, Integer, String +from sqlalchemy.orm import Mapped, mapped_column + +from .db import Base + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +class Peer(Base): + __tablename__ = "peers" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(64), unique=True) + public_key: Mapped[str] = mapped_column(String(64), unique=True) + private_key_enc: Mapped[str] = mapped_column(String(256)) + preshared_key_enc: Mapped[str] = mapped_column(String(256)) + address: Mapped[str] = mapped_column(String(64), unique=True) + enabled: Mapped[bool] = mapped_column(Boolean, default=True) + expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + + +class TrafficSample(Base): + __tablename__ = "traffic_samples" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + peer_public_key: Mapped[str] = mapped_column(String(64), index=True) + rx_bytes: Mapped[int] = mapped_column(Integer, default=0) + tx_bytes: Mapped[int] = mapped_column(Integer, default=0) + sampled_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True) diff --git a/app/service.py b/app/service.py new file mode 100644 index 0000000..a923284 --- /dev/null +++ b/app/service.py @@ -0,0 +1,91 @@ +from datetime import datetime, timezone + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from . import wireguard +from .crypto import encrypt +from .models import Peer, TrafficSample + + +def list_peers(db: Session) -> list[Peer]: + return list(db.scalars(select(Peer).order_by(Peer.id))) + + +def get_peer(db: Session, peer_id: int) -> Peer | None: + return db.get(Peer, peer_id) + + +def create_peer(db: Session, name: str, expires_at: datetime | None = None) -> Peer: + private, public = wireguard.generate_keypair() + psk = wireguard.genpsk() + address = wireguard.next_free_address([p.address for p in list_peers(db)]) + peer = Peer( + name=name, + public_key=public, + private_key_enc=encrypt(private), + preshared_key_enc=encrypt(psk), + address=address, + expires_at=expires_at, + ) + db.add(peer) + db.commit() + apply_config(db) + return peer + + +def rotate_peer_keys(db: Session, peer: Peer) -> Peer: + private, public = wireguard.generate_keypair() + peer.public_key = public + peer.private_key_enc = encrypt(private) + peer.preshared_key_enc = encrypt(wireguard.genpsk()) + db.commit() + apply_config(db) + return peer + + +def toggle_peer(db: Session, peer: Peer) -> Peer: + peer.enabled = not peer.enabled + db.commit() + apply_config(db) + return peer + + +def delete_peer(db: Session, peer: Peer) -> None: + db.delete(peer) + db.commit() + apply_config(db) + + +def disable_expired_peers(db: Session) -> bool: + now = datetime.now(timezone.utc) + changed = False + for peer in list_peers(db): + expires = peer.expires_at + if expires is not None and expires.tzinfo is None: + expires = expires.replace(tzinfo=timezone.utc) + if peer.enabled and expires is not None and expires <= now: + peer.enabled = False + changed = True + if changed: + db.commit() + apply_config(db) + return changed + + +def apply_config(db: Session) -> None: + private, _ = wireguard.ensure_server_keys() + wireguard.sync_peers(private, list_peers(db)) + + +def sample_traffic(db: Session) -> None: + status = wireguard.get_status() + for peer_status in status.peers.values(): + db.add( + TrafficSample( + peer_public_key=peer_status.public_key, + rx_bytes=peer_status.rx_bytes, + tx_bytes=peer_status.tx_bytes, + ) + ) + db.commit() diff --git a/app/static/status.js b/app/static/status.js new file mode 100644 index 0000000..6e2d14b --- /dev/null +++ b/app/static/status.js @@ -0,0 +1,64 @@ +const REFRESH_MS = 5000; + +function fmtBytes(num) { + const units = ["B", "KiB", "MiB", "GiB", "TiB"]; + let i = 0; + while (Math.abs(num) >= 1024 && i < units.length - 1) { + num /= 1024; + i++; + } + return num.toFixed(1) + " " + units[i]; +} + +function fmtHandshake(iso) { + if (!iso) return "never"; + const seconds = Math.floor((Date.now() - new Date(iso).getTime()) / 1000); + if (seconds < 60) return seconds + "s ago"; + if (seconds < 3600) return Math.floor(seconds / 60) + "m ago"; + return Math.floor(seconds / 3600) + "h ago"; +} + +async function refresh() { + let data; + try { + const res = await fetch("/api/status"); + if (!res.ok) return; + data = await res.json(); + } catch { + return; + } + + const online = data.peers.filter((p) => p.online).length; + const set = (id, value) => { + const el = document.getElementById(id); + if (el) el.textContent = value; + }; + set("peer-count", data.peers.length); + set("online-count", online); + set("total-rx", fmtBytes(data.interface.total_rx)); + set("total-tx", fmtBytes(data.interface.total_tx)); + + const tbody = document.querySelector("#peer-table tbody"); + if (!tbody) return; + tbody.innerHTML = ""; + for (const p of data.peers) { + const tr = document.createElement("tr"); + const badge = p.online + ? 'online' + : p.enabled + ? 'offline' + : 'disabled'; + tr.innerHTML = + `` + + `${p.address}` + + `${badge}` + + `${fmtHandshake(p.latest_handshake)}` + + `${fmtBytes(p.rx_bytes)}` + + `${fmtBytes(p.tx_bytes)}`; + tr.querySelector("a").textContent = p.name; + tbody.appendChild(tr); + } +} + +refresh(); +setInterval(refresh, REFRESH_MS); diff --git a/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000..921df26 --- /dev/null +++ b/app/static/style.css @@ -0,0 +1,88 @@ +* { box-sizing: border-box; } +body { + margin: 0; + font-family: system-ui, -apple-system, sans-serif; + background: #f4f5f7; + color: #1f2430; +} +nav { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem 1.5rem; + background: #1f2430; + color: #fff; +} +nav .brand { font-weight: 700; margin-right: 1rem; } +nav a { color: #cdd3e0; text-decoration: none; } +nav a:hover { color: #fff; } +nav form { margin-left: auto; } +main { max-width: 960px; margin: 0 auto; padding: 1.5rem; } +h1 { margin-top: 0; } + +.cards { display: flex; gap: 1rem; flex-wrap: wrap; } +.card { + background: #fff; + border-radius: 8px; + padding: 1rem 1.25rem; + box-shadow: 0 1px 3px rgba(0,0,0,0.08); + flex: 1 1 220px; +} +.card h3 { margin: 0 0 0.5rem; font-size: 0.85rem; text-transform: uppercase; color: #6b7280; } + +table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; } +th, td { text-align: left; padding: 0.6rem 0.8rem; border-bottom: 1px solid #e5e7eb; } +thead th { background: #eef0f4; font-size: 0.8rem; text-transform: uppercase; color: #6b7280; } +table.kv { background: transparent; } +table.kv th { width: 200px; color: #6b7280; } + +.badge { padding: 0.15rem 0.5rem; border-radius: 999px; font-size: 0.75rem; } +.badge.ok { background: #d1fae5; color: #065f46; } +.badge.err { background: #fee2e2; color: #991b1b; } +.badge.muted { background: #e5e7eb; color: #4b5563; } +.muted { color: #6b7280; } +.error { color: #991b1b; } + +button, .button { + background: #2563eb; + color: #fff; + border: none; + border-radius: 6px; + padding: 0.45rem 0.9rem; + cursor: pointer; + font-size: 0.9rem; + text-decoration: none; + display: inline-block; +} +button:hover, .button:hover { background: #1d4ed8; } +button.danger { background: #dc2626; } +button.danger:hover { background: #b91c1c; } +button.link { background: none; padding: 0; color: #cdd3e0; } +button.link:hover { color: #fff; background: none; } +form.inline { display: inline; } +.actions form { margin-right: 0.25rem; } +.actions-row { margin-top: 1rem; display: flex; gap: 0.5rem; } + +input { + padding: 0.45rem 0.6rem; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 0.9rem; +} +.add-peer { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; } + +.login-page { display: flex; align-items: center; justify-content: center; min-height: 100vh; } +.login-box { + background: #fff; + padding: 2rem; + border-radius: 10px; + box-shadow: 0 4px 16px rgba(0,0,0,0.1); + width: 320px; +} +.login-box h1 { font-size: 1.3rem; text-align: center; } +.login-box label { display: block; margin-bottom: 0.75rem; font-size: 0.9rem; } +.login-box input { width: 100%; margin-top: 0.25rem; } +.login-box button { width: 100%; margin-top: 0.5rem; } + +.qr { max-width: 220px; display: block; margin-bottom: 0.5rem; } +code { word-break: break-all; font-size: 0.85em; } diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..9ff4e81 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,24 @@ + + + + + + {% block title %}WireGuard Admin{% endblock %} + + + + +
+{% block content %}{% endblock %} +
+{% block scripts %}{% endblock %} + + diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html new file mode 100644 index 0000000..e8b80c1 --- /dev/null +++ b/app/templates/dashboard.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% block title %}Dashboard - WireGuard Admin{% endblock %} +{% block content %} +

Dashboard

+
+
+

Interface

+

{{ status.name }} + {% if status.up %}up + {% else %}down{% endif %} +

+

Port {{ status.listen_port or settings.wg_port }}

+
+
+

Peers

+

{{ peers | length }} total, + - online

+
+
+

Traffic

+

RX {{ status.total_rx | fmt_bytes }}

+

TX {{ status.total_tx | fmt_bytes }}

+
+
+ +

Peers

+ + + + + +
NameAddressStatusHandshakeRXTX
+{% endblock %} +{% block scripts %}{% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..ddfc679 --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,20 @@ + + + + + + Login - WireGuard Admin + + + +
+

WireGuard Admin

+ {% if error %}

{{ error }}

{% endif %} +
+ + + +
+
+ + diff --git a/app/templates/peer_detail.html b/app/templates/peer_detail.html new file mode 100644 index 0000000..4b0a4c7 --- /dev/null +++ b/app/templates/peer_detail.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} +{% block title %}{{ peer.name }} - WireGuard Admin{% endblock %} +{% block content %} +

{{ peer.name }}

+
+
+

Details

+

Address: {{ peer.address }}

+

Public key: {{ peer.public_key }}

+

Enabled: + {% if peer.enabled %}yes + {% else %}no{% endif %} +

+

Created {{ peer.created_at.strftime("%Y-%m-%d %H:%M") }}

+ {% if peer.expires_at %}

Expires {{ peer.expires_at.strftime("%Y-%m-%d %H:%M") }}

{% endif %} +
+
+

Live status

+ {% if peer_status %} +

Status: {% if peer_status.online %}online{% else %}offline{% endif %}

+

Endpoint: {{ peer_status.endpoint or "-" }}

+

Handshake: {{ peer_status.latest_handshake.strftime("%Y-%m-%d %H:%M:%S") if peer_status.latest_handshake else "never" }}

+

RX {{ peer_status.rx_bytes | fmt_bytes }} / TX {{ peer_status.tx_bytes | fmt_bytes }}

+ {% else %} +

Not present on interface (disabled or interface down).

+ {% endif %} +
+
+

Client config

+ QR code +

Download .conf

+
+
+ +
+
+ +
+
+ +
+
+ +
+
+{% endblock %} diff --git a/app/templates/peers.html b/app/templates/peers.html new file mode 100644 index 0000000..f5f8873 --- /dev/null +++ b/app/templates/peers.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% block title %}Peers - WireGuard Admin{% endblock %} +{% block content %} +

Peers

+ +
+ + + +
+ + + + + + + {% for peer in peers %} + {% set ps = status.peers.get(peer.public_key) %} + + + + + + + + + {% else %} + + {% endfor %} + +
NameAddressEnabledStatusCreated
{{ peer.name }}{{ peer.address }}{% if peer.enabled %}enabled{% else %}disabled{% endif %}{% if ps and ps.online %}online{% else %}offline{% endif %}{{ peer.created_at.strftime("%Y-%m-%d") }} +
+ +
+
+ +
+
No peers yet. Add one above.
+{% endblock %} diff --git a/app/templates/settings.html b/app/templates/settings.html new file mode 100644 index 0000000..bfdf76e --- /dev/null +++ b/app/templates/settings.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% block title %}Settings - WireGuard Admin{% endblock %} +{% block content %} +

Settings

+
+ + + + + + + + + +
Interface{{ settings.wg_interface }}
Server public key{{ server_public }}
Endpoint host{{ settings.wg_host }}
Listen port{{ settings.wg_port }}
Subnet{{ settings.wg_subnet }}
Client DNS{{ settings.wg_dns }}
Client AllowedIPs{{ settings.wg_allowed_ips }}
Keepalive{{ settings.wg_persistent_keepalive }}s
+

Settings are read from environment variables / .env and require a restart to change.

+
+{% endblock %} diff --git a/app/wireguard.py b/app/wireguard.py new file mode 100644 index 0000000..f3a3ae9 --- /dev/null +++ b/app/wireguard.py @@ -0,0 +1,190 @@ +import ipaddress +import subprocess +from dataclasses import dataclass, field +from datetime import datetime, timezone + +from .config import settings +from .crypto import decrypt +from .models import Peer + +ONLINE_THRESHOLD_SECONDS = 180 + + +def _run(args: list[str], input_text: str | None = None) -> str: + result = subprocess.run( + args, input=input_text, capture_output=True, text=True, check=True + ) + return result.stdout.strip() + + +def genkey() -> str: + return _run(["wg", "genkey"]) + + +def genpsk() -> str: + return _run(["wg", "genpsk"]) + + +def pubkey(private_key: str) -> str: + return _run(["wg", "pubkey"], input_text=private_key) + + +def generate_keypair() -> tuple[str, str]: + private = genkey() + return private, pubkey(private) + + +@dataclass +class PeerStatus: + public_key: str + endpoint: str | None = None + latest_handshake: datetime | None = None + rx_bytes: int = 0 + tx_bytes: int = 0 + + @property + def online(self) -> bool: + if self.latest_handshake is None: + return False + delta = datetime.now(timezone.utc) - self.latest_handshake + return delta.total_seconds() < ONLINE_THRESHOLD_SECONDS + + +@dataclass +class InterfaceStatus: + name: str + public_key: str | None = None + listen_port: int | None = None + up: bool = False + peers: dict[str, PeerStatus] = field(default_factory=dict) + + @property + def total_rx(self) -> int: + return sum(p.rx_bytes for p in self.peers.values()) + + @property + def total_tx(self) -> int: + return sum(p.tx_bytes for p in self.peers.values()) + + +def get_status() -> InterfaceStatus: + status = InterfaceStatus(name=settings.wg_interface) + try: + output = _run(["wg", "show", settings.wg_interface, "dump"]) + except (subprocess.CalledProcessError, FileNotFoundError): + return status + + status.up = True + lines = output.splitlines() + if lines: + fields = lines[0].split("\t") + if len(fields) >= 3: + status.public_key = fields[1] + status.listen_port = int(fields[2]) + for line in lines[1:]: + fields = line.split("\t") + if len(fields) < 8: + continue + peer = PeerStatus(public_key=fields[0]) + if fields[2] != "(none)": + peer.endpoint = fields[2] + handshake = int(fields[4]) + if handshake: + peer.latest_handshake = datetime.fromtimestamp(handshake, tz=timezone.utc) + peer.rx_bytes = int(fields[5]) + peer.tx_bytes = int(fields[6]) + status.peers[peer.public_key] = peer + return status + + +def _server_key_paths() -> tuple: + key_dir = settings.data_dir / "server" + return key_dir / "privatekey", key_dir / "publickey" + + +def ensure_server_keys() -> tuple[str, str]: + private_path, public_path = _server_key_paths() + if private_path.exists() and public_path.exists(): + return private_path.read_text().strip(), public_path.read_text().strip() + private_path.parent.mkdir(parents=True, exist_ok=True) + private, public = generate_keypair() + private_path.touch(mode=0o600) + private_path.write_text(private + "\n") + public_path.write_text(public + "\n") + return private, public + + +def server_address() -> str: + network = ipaddress.ip_network(settings.wg_subnet) + return f"{next(network.hosts())}/{network.prefixlen}" + + +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} + hosts = network.hosts() + used.add(next(hosts)) + for host in hosts: + if host not in used: + return f"{host}/32" + raise RuntimeError(f"No free addresses left in {settings.wg_subnet}") + + +def render_server_config(private_key: str, peers: list[Peer]) -> str: + lines = [ + "[Interface]", + f"PrivateKey = {private_key}", + f"Address = {server_address()}", + f"ListenPort = {settings.wg_port}", + ] + for peer in peers: + if not peer.enabled: + continue + lines += [ + "", + "[Peer]", + f"# {peer.name}", + f"PublicKey = {peer.public_key}", + f"PresharedKey = {decrypt(peer.preshared_key_enc)}", + f"AllowedIPs = {peer.address}", + ] + return "\n".join(lines) + "\n" + + +def render_client_config(peer: Peer, server_public_key: str) -> str: + return "\n".join( + [ + "[Interface]", + f"PrivateKey = {decrypt(peer.private_key_enc)}", + f"Address = {peer.address}", + f"DNS = {settings.wg_dns}", + "", + "[Peer]", + f"PublicKey = {server_public_key}", + f"PresharedKey = {decrypt(peer.preshared_key_enc)}", + f"Endpoint = {settings.wg_host}:{settings.wg_port}", + f"AllowedIPs = {settings.wg_allowed_ips}", + f"PersistentKeepalive = {settings.wg_persistent_keepalive}", + ] + ) + "\n" + + +def write_server_config(private_key: str, peers: list[Peer]) -> None: + settings.wg_config_dir.mkdir(parents=True, exist_ok=True) + config_path = settings.wg_config_dir / f"{settings.wg_interface}.conf" + config_path.touch(mode=0o600) + config_path.write_text(render_server_config(private_key, peers)) + + +def interface_up() -> None: + if not get_status().up: + _run(["wg-quick", "up", settings.wg_interface]) + + +def sync_peers(private_key: str, peers: list[Peer]) -> None: + write_server_config(private_key, peers) + if get_status().up: + stripped = _run( + ["wg-quick", "strip", str(settings.wg_config_dir / f"{settings.wg_interface}.conf")] + ) + _run(["wg", "syncconf", settings.wg_interface, "/dev/stdin"], input_text=stripped) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..124e0e4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,31 @@ +services: + wireguard-admin: + build: . + container_name: wireguard-admin + restart: unless-stopped + cap_add: + - NET_ADMIN + sysctls: + - net.ipv4.ip_forward=1 + - net.ipv4.conf.all.src_valid_mark=1 + devices: + - /dev/net/tun:/dev/net/tun + ports: + - "51820:51820/udp" + - "127.0.0.1:8000:8000/tcp" + environment: + WG_INTERFACE: wg0 + WG_HOST: ${WG_HOST:?set WG_HOST to your public IP or domain} + WG_PORT: "51820" + WG_SUBNET: 10.8.0.0/24 + WG_DNS: 1.1.1.1 + ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD} + SECRET_KEY: ${SECRET_KEY:?set SECRET_KEY to a long random string} + volumes: + - wg-admin-data:/opt/wireguard-admin/data + - wg-config:/etc/wireguard + +volumes: + wg-admin-data: + wg-config: diff --git a/docs/FEATURES.md b/docs/FEATURES.md new file mode 100644 index 0000000..99a6e9f --- /dev/null +++ b/docs/FEATURES.md @@ -0,0 +1,64 @@ +# WireGuard Admin Panel - Feature Specification + +Stack: FastAPI + Jinja2 (server-rendered) + SQLite + minimal JS, deployed in Docker with NET_ADMIN. + +## 1. Auto Configuration + +- On first start, auto-generate the server keypair if none exists. +- Auto-create and bring up the WireGuard interface (`wg0`) from settings (subnet, port, DNS). +- Auto-assign the next free IP from the subnet when a peer is created. +- Apply peer changes live with `wg syncconf` (no tunnel drops). +- NAT/forwarding rules (iptables) set up automatically inside the container. +- All settings configurable via environment variables / `.env`. + +## 2. Client Key Management + +- Generate keypair (private/public) and preshared key per peer on creation. +- Regenerate (rotate) a peer's keys on demand; old key is revoked immediately. +- Peer private keys stored encrypted at rest in SQLite. +- Export client config as downloadable `.conf` file. +- Export client config as QR code (for mobile apps), rendered inline. +- Enable / disable a peer without deleting it. +- Delete (revoke) a peer, removed from the live interface immediately. +- Optional peer expiry date, expired peers are auto-disabled. + +## 3. Monitoring + +- Dashboard: interface state, listen port, public key, peer count, total RX/TX. +- Per-peer live status: last handshake, online/offline (handshake < 3 min), endpoint, RX/TX bytes. +- Auto-refreshing status via a JSON endpoint polled by the page (no full reload). +- Traffic history sampled periodically and stored, shown as simple usage charts. + +## 4. Authentication + +- Single admin account (username/password from env, bcrypt hashed). +- Session cookie login (signed, HTTP-only), logout, session expiry. +- All pages and API endpoints require login. + +## 5. Web UI Pages + +| Page | Purpose | +|------|---------| +| /login | Admin login | +| / | Dashboard (interface status, totals, recent peers) | +| /peers | Peer list with online status, add/enable/disable/delete | +| /peers/{id} | Peer detail: config download, QR code, key rotation, stats | +| /settings | Server settings view (interface, port, subnet, DNS) | + +## 6. JSON API (used by UI, reusable for automation) + +| Method | Path | Purpose | +|--------|------|---------| +| GET | /api/status | Interface + all peers live stats | +| POST | /api/peers | Create peer | +| POST | /api/peers/{id}/toggle | Enable/disable peer | +| POST | /api/peers/{id}/rotate | Rotate peer keys | +| DELETE | /api/peers/{id} | Delete peer | +| GET | /api/peers/{id}/config | Download .conf | +| GET | /api/peers/{id}/qr | QR code PNG | + +## 7. Deployment + +- Dockerfile (python slim + wireguard-tools + iptables). +- docker-compose.yml with `cap_add: NET_ADMIN`, sysctl ip_forward, UDP 51820, panel on 8000. +- Persistent volume for SQLite DB and generated configs. diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..d55c86b --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,15 @@ +#!/bin/sh +set -e + +WG_INTERFACE="${WG_INTERFACE:-wg0}" +WG_SUBNET="${WG_SUBNET:-10.8.0.0/24}" +OUT_IFACE="$(ip route show default | awk '/default/ {print $5; exit}')" + +iptables -t nat -C POSTROUTING -s "$WG_SUBNET" -o "$OUT_IFACE" -j MASQUERADE 2>/dev/null \ + || iptables -t nat -A POSTROUTING -s "$WG_SUBNET" -o "$OUT_IFACE" -j MASQUERADE +iptables -C FORWARD -i "$WG_INTERFACE" -j ACCEPT 2>/dev/null \ + || iptables -A FORWARD -i "$WG_INTERFACE" -j ACCEPT +iptables -C FORWARD -o "$WG_INTERFACE" -j ACCEPT 2>/dev/null \ + || iptables -A FORWARD -o "$WG_INTERFACE" -j ACCEPT + +exec uvicorn app.main:app --host 0.0.0.0 --port 8000 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c6c7123 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +fastapi>=0.115 +uvicorn[standard]>=0.32 +jinja2>=3.1 +python-multipart>=0.0.12 +sqlalchemy>=2.0 +itsdangerous>=2.2 +bcrypt>=4.2 +qrcode[pil]>=8.0 +cryptography>=43.0 +pydantic-settings>=2.6