Advanced networking: per-peer site subnets (site-to-site), per-peer client routes, peer isolation option, client setup guide

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-07-04 08:21:17 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent d10e9fa346
commit 192960ad3e
12 changed files with 176 additions and 6 deletions
+1
View File
@@ -12,6 +12,7 @@ class Settings(BaseSettings):
wg_allowed_ips: str = "0.0.0.0/0, ::/0"
wg_persistent_keepalive: int = 25
wg_config_dir: Path = Path("/etc/wireguard")
wg_peer_isolation: bool = False
admin_username: str = "admin"
admin_password: str = "changeme"
+2
View File
@@ -26,6 +26,8 @@ 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 ''",
"extra_allowed_ips": "ALTER TABLE peers ADD COLUMN extra_allowed_ips VARCHAR(512) NOT NULL DEFAULT ''",
"client_allowed_ips": "ALTER TABLE peers ADD COLUMN client_allowed_ips VARCHAR(512) 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",
+34 -3
View File
@@ -139,6 +139,8 @@ def create_peer(
count: int = Form(1),
address: str = Form(""),
dns: str = Form(""),
extra_allowed_ips: str = Form(""),
client_allowed_ips: str = Form(""),
):
expiry = datetime.fromisoformat(expires_at) if expires_at else None
quota = _parse_quota_gib(quota_gib)
@@ -149,7 +151,15 @@ def create_peer(
)
return RedirectResponse("/peers", status_code=303)
peer = service.create_peer(
db, name.strip(), expiry, note.strip(), quota, address.strip(), dns.strip()
db,
name.strip(),
expiry,
note.strip(),
quota,
address.strip(),
dns.strip(),
extra_allowed_ips.strip(),
client_allowed_ips.strip(),
)
except ValueError as exc:
return RedirectResponse(f"/peers?error={quote(str(exc))}", status_code=303)
@@ -164,7 +174,9 @@ def _get_peer_or_404(db: Session, peer_id: int):
@app.get("/peers/{peer_id}", response_class=HTMLResponse, dependencies=[logged_in])
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), error: str = ""
):
peer = _get_peer_or_404(db, peer_id)
status = wireguard.get_status()
_, server_public = wireguard.ensure_server_keys()
@@ -176,6 +188,8 @@ def peer_detail(request: Request, peer_id: int, db: Session = Depends(get_db)):
"peer": peer,
"peer_status": status.peers.get(peer.public_key),
"client_config": client_config,
"error": error,
"server_tunnel_ip": wireguard.server_address().split("/")[0],
},
)
@@ -187,9 +201,24 @@ def update_peer(
note: str = Form(""),
quota_gib: str = Form(""),
dns: str = Form(""),
extra_allowed_ips: str = Form(""),
client_allowed_ips: str = Form(""),
):
peer = _get_peer_or_404(db, peer_id)
service.update_peer(db, peer, note.strip(), _parse_quota_gib(quota_gib), dns.strip())
try:
service.update_peer(
db,
peer,
note.strip(),
_parse_quota_gib(quota_gib),
dns.strip(),
extra_allowed_ips.strip(),
client_allowed_ips.strip(),
)
except ValueError as exc:
return RedirectResponse(
f"/peers/{peer_id}?error={quote(str(exc))}", status_code=303
)
return RedirectResponse(f"/peers/{peer_id}", status_code=303)
@@ -274,6 +303,8 @@ def api_status(db: Session = Depends(get_db)):
"address": peer.address,
"enabled": peer.enabled,
"note": peer.note,
"extra_allowed_ips": peer.extra_allowed_ips,
"client_allowed_ips": peer.client_allowed_ips,
"quota_bytes": peer.quota_bytes,
"cum_rx": peer.cum_rx,
"cum_tx": peer.cum_tx,
+2
View File
@@ -24,6 +24,8 @@ class Peer(Base):
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="")
extra_allowed_ips: Mapped[str] = mapped_column(String(512), default="")
client_allowed_ips: Mapped[str] = mapped_column(String(512), 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)
+15 -1
View File
@@ -24,6 +24,8 @@ def create_peer(
quota_bytes: int = 0,
address: str = "",
dns: str = "",
extra_allowed_ips: str = "",
client_allowed_ips: str = "",
) -> Peer:
private, public = wireguard.generate_keypair()
psk = wireguard.genpsk()
@@ -42,6 +44,8 @@ def create_peer(
note=note,
quota_bytes=quota_bytes,
dns=dns,
extra_allowed_ips=wireguard.validate_cidr_list(extra_allowed_ips),
client_allowed_ips=wireguard.validate_cidr_list(client_allowed_ips),
)
db.add(peer)
db.commit()
@@ -69,10 +73,20 @@ def create_peers_batch(
return peers
def update_peer(db: Session, peer: Peer, note: str, quota_bytes: int, dns: str = "") -> Peer:
def update_peer(
db: Session,
peer: Peer,
note: str,
quota_bytes: int,
dns: str = "",
extra_allowed_ips: str = "",
client_allowed_ips: str = "",
) -> Peer:
peer.note = note
peer.quota_bytes = quota_bytes
peer.dns = dns
peer.extra_allowed_ips = wireguard.validate_cidr_list(extra_allowed_ips)
peer.client_allowed_ips = wireguard.validate_cidr_list(client_allowed_ips)
db.commit()
if not peer.over_quota:
apply_config(db)
+53
View File
@@ -2,6 +2,9 @@
{% block title %}{{ peer.name }} - WireGuard Admin{% endblock %}
{% block breadcrumbs %}<span>/</span> <a href="/peers" class="text-blue-600 hover:underline">Peers</a> <span>/</span> <span>{{ peer.name }}</span>{% endblock %}
{% block content %}
{% if error %}
<p class="mb-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">{{ error }}</p>
{% endif %}
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold">{{ peer.name }}</h1>
<div class="flex gap-2">
@@ -93,6 +96,16 @@
value="{{ '%.1f' % (peer.quota_bytes / 1073741824) if peer.quota_bytes else '' }}"
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
</label>
<label class="block text-sm font-medium text-slate-600">Site subnets behind this peer
<input name="extra_allowed_ips" value="{{ peer.extra_allowed_ips }}" placeholder="e.g. 192.168.5.0/24"
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
<span class="mt-1 block text-xs text-slate-400">Server-side AllowedIPs: routes these networks to this peer (site-to-site). Peer must forward traffic.</span>
</label>
<label class="block text-sm font-medium text-slate-600">Client routes (AllowedIPs)
<input name="client_allowed_ips" value="{{ peer.client_allowed_ips }}" placeholder="server default"
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
<span class="mt-1 block text-xs text-slate-400">What this client routes through the tunnel. Empty = server default. Full tunnel: 0.0.0.0/0, ::/0</span>
</label>
<button type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700">
Save
@@ -153,6 +166,46 @@
<pre id="client-config" class="overflow-x-auto rounded-lg bg-slate-900 p-4 text-xs leading-relaxed text-slate-200">{{ client_config }}</pre>
</div>
<div class="mt-4 rounded-xl bg-white p-5 shadow-sm">
<h3 class="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-400">Client setup guide</h3>
<div class="space-y-5 text-sm">
<div>
<h4 class="mb-2 font-semibold text-slate-700">Linux (wg-quick)</h4>
<pre class="overflow-x-auto rounded-lg bg-slate-900 p-4 text-xs leading-relaxed text-slate-200"># Install wireguard tools
sudo apt install wireguard # Debian/Ubuntu
sudo dnf install wireguard-tools # Fedora/RHEL
# Save the config (downloaded or copied from above)
sudo mv {{ peer.name }}.conf /etc/wireguard/{{ peer.name }}.conf
sudo chmod 600 /etc/wireguard/{{ peer.name }}.conf
# Bring the tunnel up / down
sudo wg-quick up {{ peer.name }}
sudo wg-quick down {{ peer.name }}
# Start automatically at boot
sudo systemctl enable --now wg-quick@{{ peer.name }}
# Verify: check handshake and ping the server
sudo wg show
ping {{ server_tunnel_ip }}</pre>
</div>
<div>
<h4 class="mb-2 font-semibold text-slate-700">Linux (NetworkManager)</h4>
<pre class="overflow-x-auto rounded-lg bg-slate-900 p-4 text-xs leading-relaxed text-slate-200">nmcli connection import type wireguard file {{ peer.name }}.conf
nmcli connection up {{ peer.name }}</pre>
</div>
<div>
<h4 class="mb-2 font-semibold text-slate-700">macOS / Windows</h4>
<p class="text-slate-500">Install the official WireGuard app, then Import Tunnel from File and select the downloaded <span class="font-mono text-xs">{{ peer.name }}.conf</span>, or Add Empty Tunnel and paste the config above.</p>
</div>
<div>
<h4 class="mb-2 font-semibold text-slate-700">iOS / Android</h4>
<p class="text-slate-500">Install the WireGuard app, tap Add Tunnel, then Create from QR code and scan the QR code above.</p>
</div>
</div>
</div>
<script>
function copyConfig(btn) {
navigator.clipboard.writeText(document.getElementById("client-config").textContent)
+8
View File
@@ -39,6 +39,14 @@
<input name="expires_at" type="datetime-local"
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
</label>
<label class="block text-sm font-medium text-slate-600">Site subnets
<input name="extra_allowed_ips" placeholder="e.g. 192.168.5.0/24"
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
</label>
<label class="block text-sm font-medium text-slate-600">Client routes
<input name="client_allowed_ips" placeholder="server default"
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
</label>
<div class="flex items-end">
<button type="submit"
class="w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700">
+4
View File
@@ -41,6 +41,10 @@
<dt class="text-slate-500">Keepalive</dt>
<dd class="font-medium">{{ settings.wg_persistent_keepalive }}s</dd>
</div>
<div class="flex justify-between gap-4 py-3">
<dt class="text-slate-500">Peer isolation</dt>
<dd class="font-medium">{{ "on" if settings.wg_peer_isolation else "off" }}</dd>
</div>
</dl>
<p class="mt-4 text-xs text-slate-400">Settings are read from environment variables / .env and require a restart to change.</p>
</div>
+45 -2
View File
@@ -147,6 +147,26 @@ def next_free_address(taken: list[str]) -> str:
raise RuntimeError(f"No free addresses left in {settings.wg_subnet}")
def validate_cidr_list(value: str) -> str:
networks = []
for part in value.split(","):
part = part.strip()
if not part:
continue
try:
networks.append(str(ipaddress.ip_network(part, strict=False)))
except ValueError:
raise ValueError(f"Invalid CIDR: {part}")
return ", ".join(networks)
def server_allowed_ips(peer: Peer) -> str:
allowed = peer.address
if peer.extra_allowed_ips:
allowed += f", {peer.extra_allowed_ips}"
return allowed
def render_server_config(private_key: str, peers: list[Peer]) -> str:
lines = [
"[Interface]",
@@ -154,6 +174,13 @@ def render_server_config(private_key: str, peers: list[Peer]) -> str:
f"Address = {server_address()}",
f"ListenPort = {settings.wg_port}",
]
# wg-quick adds routes for /32 peer addresses automatically; extra
# site subnets need explicit routes so return traffic enters the tunnel.
for peer in peers:
if peer.enabled and peer.extra_allowed_ips:
for subnet in peer.extra_allowed_ips.split(","):
subnet = subnet.strip()
lines.append(f"PostUp = ip route replace {subnet} dev %i")
for peer in peers:
if not peer.enabled:
continue
@@ -163,7 +190,7 @@ def render_server_config(private_key: str, peers: list[Peer]) -> str:
f"# {peer.name}",
f"PublicKey = {peer.public_key}",
f"PresharedKey = {decrypt(peer.preshared_key_enc)}",
f"AllowedIPs = {peer.address}",
f"AllowedIPs = {server_allowed_ips(peer)}",
]
return "\n".join(lines) + "\n"
@@ -180,7 +207,7 @@ def render_client_config(peer: Peer, server_public_key: str) -> str:
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"AllowedIPs = {peer.client_allowed_ips or settings.wg_allowed_ips}",
f"PersistentKeepalive = {settings.wg_persistent_keepalive}",
]
) + "\n"
@@ -209,6 +236,21 @@ def interface_up() -> None:
_run(["wg-quick", "up", settings.wg_interface])
def sync_routes(peers: list[Peer]) -> None:
for peer in peers:
if not peer.extra_allowed_ips:
continue
for subnet in peer.extra_allowed_ips.split(","):
subnet = subnet.strip()
args = ["ip", "route", "replace" if peer.enabled else "del", subnet]
if peer.enabled:
args += ["dev", settings.wg_interface]
try:
_run(args)
except subprocess.CalledProcessError:
pass
def sync_peers(private_key: str, peers: list[Peer]) -> None:
write_server_config(private_key, peers)
status = get_status()
@@ -217,3 +259,4 @@ def sync_peers(private_key: str, peers: list[Peer]) -> None:
["wg-quick", "strip", str(settings.wg_config_dir / f"{settings.wg_interface}.conf")]
)
_run(["wg", "syncconf", settings.wg_interface, "/dev/stdin"], input_text=stripped)
sync_routes(peers)