Files
wireguard-admin/app/templates/peer_detail.html
T
lofyerandfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> 1b227c2470 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>
2026-07-05 14:01:44 +08:00

254 lines
12 KiB
HTML

{% extends "base.html" %}
{% 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="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"
class="rounded-md border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-600 hover:bg-slate-100">
{{ "Disable" if peer.enabled else "Enable" }}
</button>
</form>
<form method="post" action="/peers/{{ peer.id }}/rotate"
onsubmit="return confirm('Rotate keys? The old client config stops working immediately.')">
<button type="submit"
class="rounded-md border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-600 hover:bg-slate-100">
Rotate keys
</button>
</form>
<form method="post" action="/peers/{{ peer.id }}/reset-usage"
onsubmit="return confirm('Reset usage counters to zero?')">
<button type="submit"
class="rounded-md border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-600 hover:bg-slate-100">
Reset usage
</button>
</form>
<form method="post" action="/peers/{{ peer.id }}/delete"
onsubmit="return confirm('Delete peer {{ peer.name }}?')">
<button type="submit"
class="rounded-md border border-red-200 bg-white px-3 py-1.5 text-sm font-medium text-red-600 hover:bg-red-50">
Delete
</button>
</form>
</div>
</div>
<div class="grid gap-4 lg:grid-cols-3">
<div class="rounded-xl bg-white p-5 shadow-sm">
<h3 class="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-400">Details</h3>
<dl class="space-y-2 text-sm">
<div class="flex justify-between gap-2">
<dt class="text-slate-500">Address</dt>
<dd class="font-mono text-xs">{{ peer.address }}</dd>
</div>
<div>
<dt class="text-slate-500">Public key</dt>
<dd class="mt-1 break-all font-mono text-xs text-slate-600">{{ peer.public_key }}</dd>
</div>
<div class="flex justify-between gap-2">
<dt class="text-slate-500">Enabled</dt>
<dd>
{% if peer.enabled %}
<span class="rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700">yes</span>
{% else %}
<span class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700">no</span>
{% endif %}
</dd>
</div>
<div class="flex justify-between gap-2">
<dt class="text-slate-500">Created</dt>
<dd>{{ peer.created_at.strftime("%Y-%m-%d %H:%M") }}</dd>
</div>
{% if peer.expires_at %}
<div class="flex justify-between gap-2">
<dt class="text-slate-500">Expires</dt>
<dd>{{ peer.expires_at.strftime("%Y-%m-%d %H:%M") }}</dd>
</div>
{% endif %}
<div class="flex justify-between gap-2">
<dt class="text-slate-500">Usage</dt>
<dd>
{{ peer.cum_total | fmt_bytes }}
{% if peer.quota_bytes %} / {{ peer.quota_bytes | fmt_bytes }}
{% if peer.over_quota %}
<span class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700">over quota</span>
{% endif %}
{% endif %}
</dd>
</div>
</dl>
<form method="post" action="/peers/{{ peer.id }}/update" class="mt-4 space-y-3 border-t border-slate-100 pt-4">
<label class="block text-sm font-medium text-slate-600">Note
<input name="note" value="{{ peer.note }}"
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" value="{{ peer.dns }}" 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>
<label class="block text-sm font-medium text-slate-600">Quota (GiB)
<input name="quota_gib" type="number" step="0.1" min="0" placeholder="unlimited"
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">
<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
</button>
</form>
</div>
<div class="rounded-xl bg-white p-5 shadow-sm">
<h3 class="mb-3 text-xs font-semibold uppercase tracking-wide text-slate-400">Live status</h3>
{% if peer_status %}
<dl class="space-y-2 text-sm">
<div class="flex justify-between gap-2">
<dt class="text-slate-500">Status</dt>
<dd>
{% if peer_status.online %}
<span class="rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700">online</span>
{% else %}
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-500">offline</span>
{% endif %}
</dd>
</div>
<div class="flex justify-between gap-2">
<dt class="text-slate-500">Endpoint</dt>
<dd class="font-mono text-xs">{{ peer_status.endpoint or "-" }}</dd>
</div>
<div class="flex justify-between gap-2">
<dt class="text-slate-500">Handshake</dt>
<dd>{{ peer_status.latest_handshake.strftime("%Y-%m-%d %H:%M:%S") if peer_status.latest_handshake else "never" }}</dd>
</div>
<div class="flex justify-between gap-2">
<dt class="text-slate-500">Session traffic</dt>
<dd>RX {{ peer_status.rx_bytes | fmt_bytes }} / TX {{ peer_status.tx_bytes | fmt_bytes }}</dd>
</div>
</dl>
{% else %}
<p class="text-sm text-slate-400">Not present on interface (disabled or interface down).</p>
{% endif %}
</div>
<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"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700">
Download .conf
</a>
<button type="button" onclick="copyConfig(this)"
class="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-100">
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">
<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>
{% endif %}
<script>
function copyConfig(btn) {
navigator.clipboard.writeText(document.getElementById("client-config").textContent)
.then(() => {
btn.textContent = "Copied";
setTimeout(() => { btn.textContent = "Copy"; }, 1500);
});
}
</script>
{% endblock %}