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>
This commit is contained in:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
commit
998dfe702c
@@ -0,0 +1,5 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
data/
|
||||||
|
.env
|
||||||
+18
@@ -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"]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
+40
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
+224
@@ -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
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
? '<span class="badge ok">online</span>'
|
||||||
|
: p.enabled
|
||||||
|
? '<span class="badge muted">offline</span>'
|
||||||
|
: '<span class="badge err">disabled</span>';
|
||||||
|
tr.innerHTML =
|
||||||
|
`<td><a href="/peers/${p.id}"></a></td>` +
|
||||||
|
`<td>${p.address}</td>` +
|
||||||
|
`<td>${badge}</td>` +
|
||||||
|
`<td>${fmtHandshake(p.latest_handshake)}</td>` +
|
||||||
|
`<td>${fmtBytes(p.rx_bytes)}</td>` +
|
||||||
|
`<td>${fmtBytes(p.tx_bytes)}</td>`;
|
||||||
|
tr.querySelector("a").textContent = p.name;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
setInterval(refresh, REFRESH_MS);
|
||||||
@@ -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; }
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{% block title %}WireGuard Admin{% endblock %}</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<span class="brand">WireGuard Admin</span>
|
||||||
|
<a href="/">Dashboard</a>
|
||||||
|
<a href="/peers">Peers</a>
|
||||||
|
<a href="/settings">Settings</a>
|
||||||
|
<form method="post" action="/logout" class="inline">
|
||||||
|
<button type="submit" class="link">Logout</button>
|
||||||
|
</form>
|
||||||
|
</nav>
|
||||||
|
<main>
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Dashboard - WireGuard Admin{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Dashboard</h1>
|
||||||
|
<div class="cards">
|
||||||
|
<div class="card">
|
||||||
|
<h3>Interface</h3>
|
||||||
|
<p>{{ status.name }}
|
||||||
|
{% if status.up %}<span class="badge ok">up</span>
|
||||||
|
{% else %}<span class="badge err">down</span>{% endif %}
|
||||||
|
</p>
|
||||||
|
<p class="muted">Port {{ status.listen_port or settings.wg_port }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Peers</h3>
|
||||||
|
<p><span id="peer-count">{{ peers | length }}</span> total,
|
||||||
|
<span id="online-count">-</span> online</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Traffic</h3>
|
||||||
|
<p>RX <span id="total-rx">{{ status.total_rx | fmt_bytes }}</span></p>
|
||||||
|
<p>TX <span id="total-tx">{{ status.total_tx | fmt_bytes }}</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Peers</h2>
|
||||||
|
<table id="peer-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Name</th><th>Address</th><th>Status</th><th>Handshake</th><th>RX</th><th>TX</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
|
{% block scripts %}<script src="/static/status.js"></script>{% endblock %}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Login - WireGuard Admin</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body class="login-page">
|
||||||
|
<div class="login-box">
|
||||||
|
<h1>WireGuard Admin</h1>
|
||||||
|
{% if error %}<p class="error">{{ error }}</p>{% endif %}
|
||||||
|
<form method="post" action="/login">
|
||||||
|
<label>Username <input name="username" autofocus required></label>
|
||||||
|
<label>Password <input name="password" type="password" required></label>
|
||||||
|
<button type="submit">Sign in</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ peer.name }} - WireGuard Admin{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>{{ peer.name }}</h1>
|
||||||
|
<div class="cards">
|
||||||
|
<div class="card">
|
||||||
|
<h3>Details</h3>
|
||||||
|
<p>Address: {{ peer.address }}</p>
|
||||||
|
<p>Public key: <code>{{ peer.public_key }}</code></p>
|
||||||
|
<p>Enabled:
|
||||||
|
{% if peer.enabled %}<span class="badge ok">yes</span>
|
||||||
|
{% else %}<span class="badge err">no</span>{% endif %}
|
||||||
|
</p>
|
||||||
|
<p class="muted">Created {{ peer.created_at.strftime("%Y-%m-%d %H:%M") }}</p>
|
||||||
|
{% if peer.expires_at %}<p class="muted">Expires {{ peer.expires_at.strftime("%Y-%m-%d %H:%M") }}</p>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Live status</h3>
|
||||||
|
{% if peer_status %}
|
||||||
|
<p>Status: {% if peer_status.online %}<span class="badge ok">online</span>{% else %}<span class="badge muted">offline</span>{% endif %}</p>
|
||||||
|
<p>Endpoint: {{ peer_status.endpoint or "-" }}</p>
|
||||||
|
<p>Handshake: {{ peer_status.latest_handshake.strftime("%Y-%m-%d %H:%M:%S") if peer_status.latest_handshake else "never" }}</p>
|
||||||
|
<p>RX {{ peer_status.rx_bytes | fmt_bytes }} / TX {{ peer_status.tx_bytes | fmt_bytes }}</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">Not present on interface (disabled or interface down).</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions-row">
|
||||||
|
<form method="post" action="/peers/{{ peer.id }}/toggle" class="inline">
|
||||||
|
<button type="submit">{{ "Disable" if peer.enabled else "Enable" }}</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/peers/{{ peer.id }}/rotate" class="inline"
|
||||||
|
onsubmit="return confirm('Rotate keys? The old client config stops working immediately.')">
|
||||||
|
<button type="submit">Rotate keys</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/peers/{{ peer.id }}/delete" class="inline"
|
||||||
|
onsubmit="return confirm('Delete peer {{ peer.name }}?')">
|
||||||
|
<button type="submit" class="danger">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Peers - WireGuard Admin{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Peers</h1>
|
||||||
|
|
||||||
|
<form method="post" action="/peers" class="add-peer">
|
||||||
|
<input name="name" placeholder="Peer name" required>
|
||||||
|
<label class="muted">Expires <input name="expires_at" type="datetime-local"></label>
|
||||||
|
<button type="submit">Add peer</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Name</th><th>Address</th><th>Enabled</th><th>Status</th><th>Created</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for peer in peers %}
|
||||||
|
{% set ps = status.peers.get(peer.public_key) %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="/peers/{{ peer.id }}">{{ peer.name }}</a></td>
|
||||||
|
<td>{{ peer.address }}</td>
|
||||||
|
<td>{% if peer.enabled %}<span class="badge ok">enabled</span>{% else %}<span class="badge err">disabled</span>{% endif %}</td>
|
||||||
|
<td>{% if ps and ps.online %}<span class="badge ok">online</span>{% else %}<span class="badge muted">offline</span>{% endif %}</td>
|
||||||
|
<td class="muted">{{ peer.created_at.strftime("%Y-%m-%d") }}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<form method="post" action="/peers/{{ peer.id }}/toggle" class="inline">
|
||||||
|
<button type="submit">{{ "Disable" if peer.enabled else "Enable" }}</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/peers/{{ peer.id }}/delete" class="inline"
|
||||||
|
onsubmit="return confirm('Delete peer {{ peer.name }}?')">
|
||||||
|
<button type="submit" class="danger">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="6" class="muted">No peers yet. Add one above.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Settings - WireGuard Admin{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Settings</h1>
|
||||||
|
<div class="card">
|
||||||
|
<table class="kv">
|
||||||
|
<tr><th>Interface</th><td>{{ settings.wg_interface }}</td></tr>
|
||||||
|
<tr><th>Server public key</th><td><code>{{ server_public }}</code></td></tr>
|
||||||
|
<tr><th>Endpoint host</th><td>{{ settings.wg_host }}</td></tr>
|
||||||
|
<tr><th>Listen port</th><td>{{ settings.wg_port }}</td></tr>
|
||||||
|
<tr><th>Subnet</th><td>{{ settings.wg_subnet }}</td></tr>
|
||||||
|
<tr><th>Client DNS</th><td>{{ settings.wg_dns }}</td></tr>
|
||||||
|
<tr><th>Client AllowedIPs</th><td>{{ settings.wg_allowed_ips }}</td></tr>
|
||||||
|
<tr><th>Keepalive</th><td>{{ settings.wg_persistent_keepalive }}s</td></tr>
|
||||||
|
</table>
|
||||||
|
<p class="muted">Settings are read from environment variables / .env and require a restart to change.</p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -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)
|
||||||
@@ -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:
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user