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:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent
192960ad3e
commit
1b227c2470
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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 >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 %}
|
||||
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user