Multi-interface management, topology view, performance tuning, precompiled CSS

- Interface model with per-interface subnet/port/keys; import/adopt existing wg-quick configs (key-less imported peers, optional key rotation), cascade delete
- Split wireguard.py into a package (keys via cryptography X25519, status, addressing, conf parse/render, sync, host tuning)
- ECharts horizontal topology view (interface -> peers -> site subnets)
- Advanced options: MTU, MSS clamping, FwMark/Table, custom PostUp/PostDown, per-peer keepalive override
- Runtime settings (sample interval/retention, online threshold, UI refresh) with traffic sample pruning; host tuning (UDP buffers, backlog, GRO forwarding)
- Precompiled Tailwind CSS replacing Play CDN runtime (fixes FOUC); stable table layout and diffed polling renders
- Host network mode in compose; NAT/isolation iptables moved into app sync

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-07-05 14:01:44 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent 192960ad3e
commit 1b227c2470
31 changed files with 2361 additions and 583 deletions
+3 -1
View File
@@ -4,14 +4,16 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}WireGuard Admin{% endblock %}</title>
<script src="/static/tailwind.js"></script>
<link rel="stylesheet" href="/static/tailwind.css">
</head>
<body class="min-h-screen bg-slate-100 text-slate-800">
<nav class="bg-slate-900 text-slate-300">
<div class="mx-auto flex max-w-5xl items-center gap-6 px-6 py-3">
<span class="text-base font-bold tracking-wide text-white">WireGuard Admin</span>
<a href="/" class="text-sm hover:text-white">Dashboard</a>
<a href="/interfaces" class="text-sm hover:text-white">Interfaces</a>
<a href="/peers" class="text-sm hover:text-white">Peers</a>
<a href="/topology" class="text-sm hover:text-white">Topology</a>
<a href="/settings" class="text-sm hover:text-white">Settings</a>
<form method="post" action="/logout" class="ml-auto">
<button type="submit" class="text-sm text-slate-400 hover:text-white">Logout</button>
+28 -22
View File
@@ -4,44 +4,50 @@
{% block content %}
<h1 class="mb-6 text-2xl font-bold">Dashboard</h1>
<div class="mb-8 grid gap-4 sm:grid-cols-3">
<div id="iface-cards" class="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{% for item in overview %}
<div class="rounded-xl bg-white p-5 shadow-sm">
<h3 class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-400">Interface</h3>
<h3 class="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-slate-400">
Interface
{% if item.iface.imported %}
<span class="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium normal-case text-amber-700">imported</span>
{% endif %}
</h3>
<p class="flex items-center gap-2 text-lg font-semibold">
{{ status.name }}
{% if status.up %}
<a href="/peers?interface={{ item.iface.id }}" class="text-blue-600 hover:underline">{{ item.iface.name }}</a>
{% if item.status.up %}
<span class="rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700">up</span>
{% else %}
<span class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700">down</span>
{% endif %}
</p>
<p class="mt-1 text-sm text-slate-500">
<span class="font-mono">{{ server_address }}</span> · Port {{ status.listen_port or settings.wg_port }}
<span class="font-mono">{{ server_address(item.iface.subnet) }}</span> · Port {{ item.status.listen_port or item.iface.listen_port }}
</p>
<p class="mt-1 text-sm text-slate-500">
{{ item.online }} / {{ item.peers | length }} peers online ·
RX {{ item.status.total_rx | fmt_bytes }} / TX {{ item.status.total_tx | fmt_bytes }}
</p>
</div>
<div class="rounded-xl bg-white p-5 shadow-sm">
<h3 class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-400">Peers</h3>
<p class="text-lg font-semibold"><span id="peer-count">{{ peers | length }}</span> total</p>
<p class="mt-1 text-sm text-slate-500"><span id="online-count">-</span> online</p>
</div>
<div class="rounded-xl bg-white p-5 shadow-sm">
<h3 class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-400">Traffic</h3>
<p class="text-sm">RX <span id="total-rx" class="font-semibold">{{ status.total_rx | fmt_bytes }}</span></p>
<p class="mt-1 text-sm">TX <span id="total-tx" class="font-semibold">{{ status.total_tx | fmt_bytes }}</span></p>
</div>
{% else %}
<p class="rounded-xl bg-white p-6 text-center text-slate-400 shadow-sm sm:col-span-2 lg:col-span-3">
No interfaces. <a href="/interfaces" class="text-blue-600 hover:underline">Create or import one.</a>
</p>
{% endfor %}
</div>
<h2 class="mb-3 text-lg font-semibold">Peers</h2>
<div class="overflow-hidden rounded-xl bg-white shadow-sm">
<table id="peer-table" class="w-full text-sm">
<table id="peer-table" class="w-full table-fixed text-sm">
<thead>
<tr class="bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-400">
<th class="px-4 py-3 font-semibold">Name</th>
<th class="px-4 py-3 font-semibold">Address</th>
<th class="px-4 py-3 font-semibold">Status</th>
<th class="px-4 py-3 font-semibold">Handshake</th>
<th class="px-4 py-3 font-semibold">RX</th>
<th class="px-4 py-3 font-semibold">TX</th>
<th class="w-[18%] px-4 py-3 font-semibold">Name</th>
<th class="w-[12%] px-4 py-3 font-semibold">Interface</th>
<th class="w-[18%] px-4 py-3 font-semibold">Address</th>
<th class="w-[12%] px-4 py-3 font-semibold">Status</th>
<th class="w-[14%] px-4 py-3 font-semibold">Handshake</th>
<th class="w-[13%] px-4 py-3 font-semibold">RX</th>
<th class="w-[13%] px-4 py-3 font-semibold">TX</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100"></tbody>
+261
View File
@@ -0,0 +1,261 @@
{% extends "base.html" %}
{% block title %}Interfaces - WireGuard Admin{% endblock %}
{% block breadcrumbs %}<span>/</span> <span>Interfaces</span>{% endblock %}
{% block content %}
<h1 class="mb-6 text-2xl font-bold">Interfaces</h1>
{% 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-8 space-y-4">
{% for item in overview %}
{% set iface = item.iface %}
<div class="rounded-xl bg-white p-5 shadow-sm">
<div class="flex flex-wrap items-center gap-3">
<h2 class="text-lg font-semibold">{{ iface.name }}</h2>
{% if item.status.up %}
<span class="rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700">up</span>
{% else %}
<span class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700">down</span>
{% endif %}
{% if not iface.enabled %}
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-500">disabled</span>
{% endif %}
{% if iface.imported %}
<span class="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700">imported</span>
{% endif %}
{% if iface.peer_isolation %}
<span class="rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-700">isolation</span>
{% endif %}
<div class="ml-auto flex gap-2">
<a href="/peers?interface={{ iface.id }}"
class="rounded-md border border-slate-300 px-3 py-1 text-xs font-medium text-slate-600 hover:bg-slate-100">
Peers ({{ item.peers | length }})
</a>
<form method="post" action="/interfaces/{{ iface.id }}/toggle"
{% if iface.enabled %}onsubmit="return confirm('Bring {{ iface.name }} down? All its peers disconnect.')"{% endif %}>
<button type="submit"
class="rounded-md border border-slate-300 px-3 py-1 text-xs font-medium text-slate-600 hover:bg-slate-100">
{{ "Down" if iface.enabled else "Up" }}
</button>
</form>
<form method="post" action="/interfaces/{{ iface.id }}/delete"
onsubmit="return confirmDelete(this, '{{ iface.name }}', {{ item.peers | length }})">
<input type="hidden" name="cascade" value="">
<button type="submit"
class="rounded-md border border-red-200 px-3 py-1 text-xs font-medium text-red-600 hover:bg-red-50">
Delete
</button>
</form>
</div>
</div>
<dl class="mt-3 grid gap-x-8 gap-y-1 text-sm sm:grid-cols-2 lg:grid-cols-4">
<div class="flex justify-between gap-2 sm:block">
<dt class="text-slate-500">Address</dt>
<dd class="font-mono text-xs">{{ server_address(iface.subnet) }}</dd>
</div>
<div class="flex justify-between gap-2 sm:block">
<dt class="text-slate-500">Endpoint</dt>
<dd class="font-mono text-xs">{{ iface.host }}:{{ iface.listen_port }}</dd>
</div>
<div class="flex justify-between gap-2 sm:block">
<dt class="text-slate-500">Online</dt>
<dd>{{ item.online }} / {{ item.peers | length }}</dd>
</div>
<div class="flex justify-between gap-2 sm:block">
<dt class="text-slate-500">Traffic</dt>
<dd>RX {{ item.status.total_rx | fmt_bytes }} / TX {{ item.status.total_tx | fmt_bytes }}</dd>
</div>
</dl>
<details class="mt-3 border-t border-slate-100 pt-3">
<summary class="cursor-pointer text-xs font-semibold uppercase tracking-wide text-slate-400">Edit</summary>
<form method="post" action="/interfaces/{{ iface.id }}/update"
class="mt-3 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<label class="block text-sm font-medium text-slate-600">Endpoint host
<input name="host" value="{{ iface.host }}" required
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 DNS
<input name="dns" value="{{ iface.dns }}"
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 AllowedIPs
<input name="allowed_ips" value="{{ iface.allowed_ips }}"
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">Keepalive (s)
<input name="persistent_keepalive" type="number" min="0" max="3600" value="{{ iface.persistent_keepalive }}"
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="flex items-end gap-2 pb-2 text-sm font-medium text-slate-600">
<input name="peer_isolation" type="checkbox" value="true" {% if iface.peer_isolation %}checked{% endif %}
class="h-4 w-4 rounded border-slate-300">
Peer isolation
</label>
<label class="block text-sm font-medium text-slate-600">MTU
<input name="mtu" type="number" min="0" max="1500" value="{{ iface.mtu or '' }}" placeholder="1420 (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">Lower for PPPoE (1412) or nested tunnels (1340). 0 = wg default.</span>
</label>
<label class="flex items-end gap-2 pb-2 text-sm font-medium text-slate-600">
<input name="mss_clamp" type="checkbox" value="true" {% if iface.mss_clamp %}checked{% endif %}
class="h-4 w-4 rounded border-slate-300">
<span>MSS clamping
<span class="block text-xs font-normal text-slate-400">Fixes "ping works but pages hang"</span>
</span>
</label>
<label class="block text-sm font-medium text-slate-600">FwMark
<input name="fwmark" value="{{ iface.fwmark }}" placeholder="e.g. 0x8888"
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">Marks tunnel packets for policy routing.</span>
</label>
<label class="block text-sm font-medium text-slate-600">Route table
<input name="route_table" value="{{ iface.route_table }}" placeholder="auto / off / table id"
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">"off" disables wg-quick routes for manual control.</span>
</label>
<label class="block text-sm font-medium text-slate-600 sm:col-span-2 lg:col-span-1">PostUp commands
<textarea name="post_up" rows="2" placeholder="one command per line"
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 font-mono text-xs focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">{{ iface.post_up }}</textarea>
</label>
<label class="block text-sm font-medium text-slate-600 sm:col-span-2 lg:col-span-1">PostDown commands
<textarea name="post_down" rows="2" placeholder="one command per line"
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 font-mono text-xs focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">{{ iface.post_down }}</textarea>
<span class="mt-1 block text-xs text-slate-400">Run as root by wg-quick on interface up/down.</span>
</label>
<div class="col-span-full">
<button type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700">
Save
</button>
</div>
</form>
<p class="mt-2 text-xs text-slate-400">
Subnet ({{ iface.subnet }}) and port are fixed after creation. Public key:
<span class="break-all font-mono">{{ iface.public_key }}</span>
</p>
</details>
</div>
{% else %}
<p class="rounded-xl bg-white p-6 text-center text-slate-400 shadow-sm">No interfaces yet. Create one below.</p>
{% endfor %}
</div>
<div class="mb-8 rounded-xl bg-white p-5 shadow-sm">
<h2 class="mb-4 text-xs font-semibold uppercase tracking-wide text-slate-400">Add interface</h2>
<form method="post" action="/interfaces" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<label class="block text-sm font-medium text-slate-600">Name
<input name="name" required placeholder="wg2" pattern="[a-zA-Z0-9_=+.-]{1,15}"
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">Subnet
<input name="subnet" required placeholder="10.9.0.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">Listen port
<input name="listen_port" type="number" min="1" max="65535" required placeholder="51822"
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">Endpoint host
<input name="host" required placeholder="vpn.example.com"
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 DNS
<input name="dns" placeholder="{{ settings.wg_dns }}"
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 AllowedIPs
<input name="allowed_ips" placeholder="{{ settings.wg_allowed_ips }}"
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">Keepalive (s)
<input name="persistent_keepalive" type="number" min="0" max="3600" value="25"
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="flex items-end gap-2 pb-2 text-sm font-medium text-slate-600">
<input name="peer_isolation" type="checkbox" value="true" class="h-4 w-4 rounded border-slate-300">
Peer isolation
</label>
<div class="col-span-full">
<button type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700">
Create interface
</button>
</div>
</form>
<p class="mt-3 text-xs text-slate-400">
With host networking the new UDP port is reachable immediately. The interface is brought up on creation.
</p>
</div>
<div class="rounded-xl bg-white p-5 shadow-sm">
<h2 class="mb-4 text-xs font-semibold uppercase tracking-wide text-slate-400">Import existing interface</h2>
{% if candidates %}
<table class="w-full text-sm">
<thead>
<tr class="bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-400">
<th class="px-4 py-3 font-semibold">Name</th>
<th class="px-4 py-3 font-semibold">State</th>
<th class="px-4 py-3 font-semibold">Address</th>
<th class="px-4 py-3 font-semibold">Port</th>
<th class="px-4 py-3 font-semibold">Peers</th>
<th class="px-4 py-3 font-semibold">Endpoint host</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for c in candidates %}
<tr>
<td class="px-4 py-3 font-medium">{{ c.name }}</td>
<td class="px-4 py-3">
{% if c.running %}
<span class="rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700">running</span>
{% else %}
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-500">stopped</span>
{% endif %}
{% if not c.has_config %}
<span class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700">no conf</span>
{% endif %}
</td>
<td class="px-4 py-3 font-mono text-xs">{{ c.address or "-" }}</td>
<td class="px-4 py-3">{{ c.listen_port or "-" }}</td>
<td class="px-4 py-3">{{ c.peer_count }}</td>
<td class="px-4 py-3" colspan="2">
<form method="post" action="/interfaces/import" class="flex gap-2">
<input type="hidden" name="name" value="{{ c.name }}">
<input name="host" required placeholder="vpn.example.com"
class="w-full rounded-md border border-slate-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
<button type="submit" {% if not c.has_config %}disabled{% endif %}
class="rounded-md bg-blue-600 px-4 py-1.5 text-sm font-semibold text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-slate-300">
Import
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<p class="mt-3 text-xs text-slate-400">
Importing takes over the wg-quick config: the server key, peers, preshared keys and site subnets are read
from the .conf (a timestamped backup is kept). Imported peers have no private key, so config download and QR
are unavailable until you rotate their keys. Endpoint host is the public address clients connect to.
</p>
{% else %}
<p class="text-sm text-slate-400">No unmanaged interfaces or configs found in the config directory.</p>
{% endif %}
</div>
<script>
function confirmDelete(form, name, peerCount) {
if (peerCount > 0) {
const cascade = confirm("Interface " + name + " has " + peerCount +
" peers.\n\nOK = delete interface AND all its peers\nCancel = abort");
if (!cascade) return false;
form.cascade.value = "true";
return true;
}
return confirm("Delete interface " + name + "?");
}
</script>
{% endblock %}
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Login - WireGuard Admin</title>
<script src="/static/tailwind.js"></script>
<link rel="stylesheet" href="/static/tailwind.css">
</head>
<body class="flex min-h-screen items-center justify-center bg-slate-900">
<div class="w-80 rounded-xl bg-white p-8 shadow-2xl">
+36 -1
View File
@@ -6,7 +6,13 @@
<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>
<h1 class="flex items-center gap-2 text-2xl font-bold">
{{ peer.name }}
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-500">{{ iface.name }}</span>
{% if not peer.has_private_key %}
<span class="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700">imported</span>
{% endif %}
</h1>
<div class="flex gap-2">
<form method="post" action="/peers/{{ peer.id }}/toggle">
<button type="submit"
@@ -96,6 +102,13 @@
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">Keepalive (s)
<input name="persistent_keepalive" type="number" min="0" max="3600"
value="{{ peer.persistent_keepalive if peer.persistent_keepalive is not none else '' }}"
placeholder="interface default ({{ iface.persistent_keepalive }})"
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">0 disables keepalive (fixed-IP site-to-site peers). Empty = interface default.</span>
</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">
@@ -147,6 +160,7 @@
<div class="rounded-xl bg-white p-5 shadow-sm">
<h3 class="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-400">Client config</h3>
{% if peer.has_private_key %}
<img src="/peers/{{ peer.id }}/qr" alt="QR code" class="mx-auto w-48 rounded-lg border border-slate-200">
<div class="mt-4 flex justify-center gap-2">
<a href="/peers/{{ peer.id }}/config"
@@ -158,14 +172,34 @@
Copy
</button>
</div>
{% else %}
<p class="text-sm text-slate-500">
This peer was imported from an existing config, so the server only knows its public key.
The client keeps using its current config and everything works, but the panel cannot
generate a .conf or QR code.
</p>
<p class="mt-3 text-sm text-slate-500">
To manage the client config from here, rotate its keys and install the new config on the device.
</p>
<form method="post" action="/peers/{{ peer.id }}/rotate" class="mt-4 text-center"
onsubmit="return confirm('Rotate keys? The device stays offline until the new config is installed.')">
<button type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700">
Rotate keys and generate config
</button>
</form>
{% endif %}
</div>
</div>
{% if client_config %}
<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">{{ peer.name }}.conf</h3>
<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>
{% endif %}
{% if client_config %}
<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">
@@ -205,6 +239,7 @@ nmcli connection up {{ peer.name }}</pre>
</div>
</div>
</div>
{% endif %}
<script>
function copyConfig(btn) {
+25 -4
View File
@@ -8,9 +8,26 @@
<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 flex-wrap gap-1 border-b border-slate-200">
{% for iface in interfaces %}
<a href="/peers?interface={{ iface.id }}"
class="rounded-t-md px-4 py-2 text-sm font-medium {% if current and iface.id == current.id %}border border-b-0 border-slate-200 bg-white text-blue-600{% else %}text-slate-500 hover:text-slate-700{% endif %}">
{{ iface.name }}
<span class="ml-1 rounded-full bg-slate-100 px-1.5 text-xs text-slate-500">{{ iface.peers | length }}</span>
</a>
{% endfor %}
<a href="/interfaces" class="ml-auto px-4 py-2 text-sm text-slate-400 hover:text-slate-600">Manage interfaces</a>
</div>
{% if not current %}
<p class="rounded-xl bg-white p-6 text-center text-slate-400 shadow-sm">
No interfaces configured. <a href="/interfaces" class="text-blue-600 hover:underline">Create one first.</a>
</p>
{% else %}
<div class="mb-8 rounded-xl bg-white p-5 shadow-sm">
<h2 class="mb-4 text-xs font-semibold uppercase tracking-wide text-slate-400">Add peer</h2>
<h2 class="mb-4 text-xs font-semibold uppercase tracking-wide text-slate-400">Add peer to {{ current.name }}</h2>
<form method="post" action="/peers" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<input type="hidden" name="interface_id" value="{{ current.id }}">
<label class="block text-sm font-medium text-slate-600">Name
<input name="name" required placeholder="laptop"
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">
@@ -24,7 +41,7 @@
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">DNS
<input name="dns" placeholder="{{ settings.wg_dns }}"
<input name="dns" placeholder="{{ current.dns }}"
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">Quota (GiB)
@@ -47,9 +64,9 @@
<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">
<div class="col-span-full">
<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">
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700">
Add peer(s)
</button>
</div>
@@ -78,6 +95,9 @@
<tr class="hover:bg-slate-50">
<td class="px-4 py-3">
<a href="/peers/{{ peer.id }}" class="font-medium text-blue-600 hover:underline">{{ peer.name }}</a>
{% if not peer.has_private_key %}
<span class="ml-1 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700">imported</span>
{% endif %}
</td>
<td class="px-4 py-3 font-mono text-xs">{{ peer.address }}</td>
<td class="px-4 py-3">
@@ -124,4 +144,5 @@
</tbody>
</table>
</div>
{% endif %}
{% endblock %}
+122 -26
View File
@@ -3,32 +3,125 @@
{% block breadcrumbs %}<span>/</span> <span>Settings</span>{% endblock %}
{% block content %}
<h1 class="mb-6 text-2xl font-bold">Settings</h1>
<div class="rounded-xl bg-white p-5 shadow-sm">
{% if error %}
<p class="mb-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">{{ error }}</p>
{% endif %}
{% if message %}
<p class="mb-4 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{{ message }}</p>
{% endif %}
<div class="mb-6 rounded-xl bg-white p-5 shadow-sm">
<h2 class="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-400">Monitoring</h2>
<form method="post" action="/settings/runtime" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<label class="block text-sm font-medium text-slate-600">Sample interval (s)
<input name="traffic_sample_interval" type="number" min="10" max="3600" value="{{ runtime.traffic_sample_interval }}"
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">Traffic/quota/expiry check frequency.</span>
</label>
<label class="block text-sm font-medium text-slate-600">Sample retention (days)
<input name="traffic_retention_days" type="number" min="0" max="3650" value="{{ runtime.traffic_retention_days }}"
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">Old samples are pruned. 0 = keep forever.</span>
</label>
<label class="block text-sm font-medium text-slate-600">Online threshold (s)
<input name="online_threshold" type="number" min="30" max="3600" value="{{ runtime.online_threshold }}"
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">Max handshake age to count a peer online.</span>
</label>
<label class="block text-sm font-medium text-slate-600">UI refresh (s)
<input name="ui_refresh_seconds" type="number" min="2" max="300" value="{{ runtime.ui_refresh_seconds }}"
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">Dashboard and topology poll interval.</span>
</label>
<div class="col-span-full">
<button type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700">
Save
</button>
</div>
</form>
</div>
<div class="mb-6 rounded-xl bg-white p-5 shadow-sm">
<h2 class="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-400">Host performance tuning</h2>
<dl class="mb-4 grid gap-x-8 gap-y-1 text-sm sm:grid-cols-2 lg:grid-cols-4">
<div class="flex justify-between gap-2 sm:block">
<dt class="text-slate-500">UDP rmem_max</dt>
<dd class="font-mono text-xs">{{ tuning.rmem_max or "?" }}</dd>
</div>
<div class="flex justify-between gap-2 sm:block">
<dt class="text-slate-500">UDP wmem_max</dt>
<dd class="font-mono text-xs">{{ tuning.wmem_max or "?" }}</dd>
</div>
<div class="flex justify-between gap-2 sm:block">
<dt class="text-slate-500">netdev_max_backlog</dt>
<dd class="font-mono text-xs">{{ tuning.netdev_max_backlog or "?" }}</dd>
</div>
<div class="flex justify-between gap-2 sm:block">
<dt class="text-slate-500">GRO forwarding ({{ tuning.out_interface or "?" }})</dt>
<dd class="font-mono text-xs">{{ tuning.gro_forwarding or "unknown" }}</dd>
</div>
</dl>
<form method="post" action="/settings/tuning" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<label class="block text-sm font-medium text-slate-600">UDP buffers (MiB)
<input name="udp_buffer_mib" type="number" min="0" max="64" placeholder="16 recommended"
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">rmem_max + wmem_max. Helps &gt;500 Mbps tunnels.</span>
</label>
<label class="block text-sm font-medium text-slate-600">netdev_max_backlog
<input name="netdev_backlog" type="number" min="0" max="100000" placeholder="e.g. 16384"
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">Receive queue for high packet rates.</span>
</label>
<label class="block text-sm font-medium text-slate-600">UDP GRO forwarding
<select name="gro_forwarding"
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">
<option value="">leave unchanged</option>
<option value="on">enable</option>
<option value="off">disable</option>
</select>
<span class="mt-1 block text-xs text-slate-400">Boosts relay/site-to-site throughput.</span>
</label>
<div class="col-span-full">
<button type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700">
Apply
</button>
</div>
</form>
<p class="mt-3 text-xs text-slate-400">
Applied immediately to the running kernel (requires host network mode and NET_ADMIN).
Empty fields are left unchanged. Settings do not survive host reboots; persist them in
/etc/sysctl.d on the host if needed.
</p>
</div>
<div class="mb-6 rounded-xl bg-white p-5 shadow-sm">
<h2 class="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-400">Interfaces</h2>
<dl class="divide-y divide-slate-100 text-sm">
{% for iface in interfaces %}
<div class="flex justify-between gap-4 py-3">
<dt class="text-slate-500">Interface</dt>
<dd class="font-medium">{{ settings.wg_interface }}</dd>
</div>
<div class="flex justify-between gap-4 py-3">
<dt class="text-slate-500">Server address</dt>
<dd class="font-mono text-xs">{{ server_address }}</dd>
</div>
<div class="flex justify-between gap-4 py-3">
<dt class="shrink-0 text-slate-500">Server public key</dt>
<dd class="break-all text-right font-mono text-xs">{{ server_public }}</dd>
</div>
<div class="flex justify-between gap-4 py-3">
<dt class="text-slate-500">Endpoint host</dt>
<dd class="font-medium">{{ settings.wg_host }}</dd>
</div>
<div class="flex justify-between gap-4 py-3">
<dt class="text-slate-500">Listen port</dt>
<dd class="font-medium">{{ settings.wg_port }}</dd>
</div>
<div class="flex justify-between gap-4 py-3">
<dt class="text-slate-500">Subnet</dt>
<dd class="font-mono text-xs">{{ settings.wg_subnet }}</dd>
<dt class="text-slate-500">{{ iface.name }}</dt>
<dd class="text-right">
<span class="font-mono text-xs">{{ server_address(iface.subnet) }}</span>
· {{ iface.host }}:{{ iface.listen_port }}
<span class="ml-2 block break-all font-mono text-xs text-slate-400 sm:inline">{{ iface.public_key }}</span>
</dd>
</div>
{% else %}
<p class="py-3 text-slate-400">No interfaces configured.</p>
{% endfor %}
</dl>
<p class="mt-2 text-xs text-slate-400">
Interfaces are managed in the database. Edit them on the
<a href="/interfaces" class="text-blue-600 hover:underline">Interfaces</a> page.
</p>
</div>
<div class="rounded-xl bg-white p-5 shadow-sm">
<h2 class="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-400">Defaults for new interfaces</h2>
<dl class="divide-y divide-slate-100 text-sm">
<div class="flex justify-between gap-4 py-3">
<dt class="text-slate-500">Client DNS</dt>
<dd class="font-medium">{{ settings.wg_dns }}</dd>
@@ -42,10 +135,13 @@
<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>
<dt class="text-slate-500">Config directory</dt>
<dd class="font-mono text-xs">{{ settings.wg_config_dir }}</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>
<p class="mt-4 text-xs text-slate-400">
These defaults come from environment variables / .env and are used when creating the first
interface on startup and as fallbacks for new interfaces.
</p>
</div>
{% endblock %}
+22
View File
@@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block title %}Topology - WireGuard Admin{% endblock %}
{% block breadcrumbs %}<span>/</span> <span>Topology</span>{% endblock %}
{% block content %}
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold">Network Topology</h1>
<div id="isolation-badge"></div>
</div>
<div class="rounded-xl bg-white p-4 shadow-sm">
<div id="topology-chart" class="w-full" style="height: 560px;"></div>
</div>
<p class="mt-3 text-sm text-slate-500">
Left to right: interface, peers, site subnets. Dashed links mean the peer is offline or disabled.
Scroll to zoom, drag to pan, click a peer to open its detail page.
</p>
{% endblock %}
{% block scripts %}
<script src="/static/echarts.min.js"></script>
<script src="/static/topology.js"></script>
{% endblock %}