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:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent
d10e9fa346
commit
192960ad3e
@@ -22,6 +22,9 @@ WG_ALLOWED_IPS=0.0.0.0/0, ::/0
|
||||
# reach the host's wg0 interface.
|
||||
WG_RELAY_SUBNETS=
|
||||
|
||||
# Set true to block peer-to-peer traffic between VPN clients (isolation).
|
||||
WG_PEER_ISOLATION=false
|
||||
|
||||
# Admin panel login
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=password
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -24,6 +24,7 @@ services:
|
||||
WG_DNS: ${WG_DNS:-8.8.8.8}
|
||||
WG_ALLOWED_IPS: ${WG_ALLOWED_IPS:-0.0.0.0/0, ::/0}
|
||||
WG_RELAY_SUBNETS: ${WG_RELAY_SUBNETS:-}
|
||||
WG_PEER_ISOLATION: ${WG_PEER_ISOLATION:-false}
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env}
|
||||
SECRET_KEY: ${SECRET_KEY:?set SECRET_KEY in .env}
|
||||
|
||||
@@ -7,6 +7,14 @@ 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
|
||||
# Peer isolation: block peer-to-peer traffic inside the wg subnet.
|
||||
if [ "${WG_PEER_ISOLATION:-false}" = "true" ]; then
|
||||
iptables -C FORWARD -i "$WG_INTERFACE" -o "$WG_INTERFACE" -j DROP 2>/dev/null \
|
||||
|| iptables -I FORWARD 1 -i "$WG_INTERFACE" -o "$WG_INTERFACE" -j DROP
|
||||
else
|
||||
iptables -D FORWARD -i "$WG_INTERFACE" -o "$WG_INTERFACE" -j DROP 2>/dev/null || true
|
||||
fi
|
||||
|
||||
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 \
|
||||
|
||||
Reference in New Issue
Block a user