Add wireguard-ui-multi core: multi-server DB, WireGuard manager, REST API, UI, installers

Implements the from-scratch multi-server WireGuard management fork per
CLAUDE.md spec: sqlite schema (servers/peers/audit_log/users), Curve25519
key generation, per-interface config rendering + wg-quick/systemd control,
nftables hook scaffolding, session+CSRF-protected REST API with QR code
and config download endpoints, a minimal vanilla-JS web UI, legacy
wg0.conf migration, and both a native installer and a Proxmox LXC
provisioning script (with auto-detected latest Debian template).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-07-10 02:53:14 +02:00
co-authored by Claude Sonnet 5
parent 3d6608ef80
commit 3b3ffd8ebf
26 changed files with 3236 additions and 0 deletions
+106
View File
@@ -0,0 +1,106 @@
function getCookie(name) {
const match = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)"));
return match ? decodeURIComponent(match[1]) : "";
}
async function apiFetch(url, options) {
options = options || {};
options.headers = options.headers || {};
if (options.method && options.method !== "GET") {
options.headers["X-CSRF-Token"] = getCookie("wgm_csrf");
}
const res = await fetch(url, options);
if (res.status === 401) {
window.location.href = "/login";
throw new Error("unauthenticated");
}
return res;
}
async function loadServers() {
const tbody = document.querySelector("#servers tbody");
tbody.innerHTML = "";
const res = await apiFetch("/api/servers");
if (!res.ok) return;
const servers = await res.json();
for (const s of servers) {
const tr = document.createElement("tr");
const nameTd = document.createElement("td");
const link = document.createElement("a");
link.href = "/servers/" + s.ID;
link.textContent = s.Name;
nameTd.appendChild(link);
const ifaceTd = document.createElement("td");
ifaceTd.textContent = s.InterfaceName;
const portTd = document.createElement("td");
portTd.textContent = s.ListenPort;
const statusTd = document.createElement("td");
const badge = document.createElement("span");
badge.className = "badge " + (s.status === "UP" ? "up" : "down");
badge.textContent = s.status;
statusTd.appendChild(badge);
const actionsTd = document.createElement("td");
actionsTd.appendChild(makeActionButton("Start", () => serverAction(s.ID, "start")));
actionsTd.appendChild(makeActionButton("Stop", () => serverAction(s.ID, "stop")));
actionsTd.appendChild(makeActionButton("Reload", () => serverAction(s.ID, "reload")));
tr.appendChild(nameTd);
tr.appendChild(ifaceTd);
tr.appendChild(portTd);
tr.appendChild(statusTd);
tr.appendChild(actionsTd);
tbody.appendChild(tr);
}
}
function makeActionButton(label, onClick) {
const btn = document.createElement("button");
btn.textContent = label;
btn.className = "secondary";
btn.addEventListener("click", onClick);
return btn;
}
async function serverAction(id, action) {
await apiFetch("/api/servers/" + id + "/" + action, { method: "POST" });
loadServers();
}
document.getElementById("new-server").addEventListener("click", async () => {
const name = prompt("Name des Servers (z.B. WGhome):");
if (!name) return;
const interfaceName = prompt("Interface (z.B. wg-home):");
if (!interfaceName) return;
const listenPort = parseInt(prompt("Listen Port (z.B. 51822):"), 10);
if (!listenPort) return;
const addressRange = prompt("Address Range (z.B. 10.20.22.0/24):");
if (!addressRange) return;
const dns = prompt("DNS (optional):") || "";
const res = await apiFetch("/api/servers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: name,
interface_name: interfaceName,
listen_port: listenPort,
address_range: addressRange,
dns: dns,
mtu: 1420,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
alert(data.error || "Server konnte nicht erstellt werden.");
return;
}
loadServers();
});
loadServers();
+25
View File
@@ -0,0 +1,25 @@
document.getElementById("login-form").addEventListener("submit", async function (e) {
e.preventDefault();
const errEl = document.getElementById("login-error");
errEl.textContent = "";
const form = e.target;
const username = form.username.value;
const password = form.password.value;
try {
const res = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
errEl.textContent = data.error || "Anmeldung fehlgeschlagen.";
return;
}
window.location.href = "/";
} catch (err) {
errEl.textContent = "Verbindung fehlgeschlagen.";
}
});
+173
View File
@@ -0,0 +1,173 @@
function getCookie(name) {
const match = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)"));
return match ? decodeURIComponent(match[1]) : "";
}
async function apiFetch(url, options) {
options = options || {};
options.headers = options.headers || {};
if (options.method && options.method !== "GET") {
options.headers["X-CSRF-Token"] = getCookie("wgm_csrf");
}
const res = await fetch(url, options);
if (res.status === 401) {
window.location.href = "/login";
throw new Error("unauthenticated");
}
return res;
}
function serverIDFromPath() {
const parts = window.location.pathname.split("/").filter(Boolean);
return parts[1];
}
const serverID = serverIDFromPath();
const errEl = document.getElementById("server-error");
async function loadServer() {
errEl.textContent = "";
const res = await apiFetch("/api/servers/" + serverID);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
errEl.textContent = data.error || "Server konnte nicht geladen werden.";
return;
}
const s = await res.json();
document.getElementById("server-name").textContent = s.Name;
document.getElementById("d-interface").textContent = s.InterfaceName;
document.getElementById("d-port").textContent = s.ListenPort;
document.getElementById("d-address").textContent = s.AddressRange;
document.getElementById("d-dns").textContent = s.DNS || "-";
document.getElementById("d-mtu").textContent = s.MTU;
const statusRes = await apiFetch("/api/servers");
if (statusRes.ok) {
const servers = await statusRes.json();
const match = servers.find((x) => String(x.ID) === String(serverID));
const statusTd = document.getElementById("d-status");
statusTd.innerHTML = "";
const badge = document.createElement("span");
const status = match ? match.status : "DOWN";
badge.className = "badge " + (status === "UP" ? "up" : "down");
badge.textContent = status;
statusTd.appendChild(badge);
}
}
async function loadPeers() {
const tbody = document.querySelector("#peers tbody");
tbody.innerHTML = "";
const res = await apiFetch("/api/server/" + serverID + "/peers");
if (!res.ok) return;
const peers = await res.json();
for (const p of peers) {
const tr = document.createElement("tr");
const nameTd = document.createElement("td");
nameTd.textContent = p.name;
const emailTd = document.createElement("td");
emailTd.textContent = p.email || "-";
const allowedTd = document.createElement("td");
allowedTd.textContent = p.allowed_ips;
const enabledTd = document.createElement("td");
enabledTd.textContent = p.enabled ? "Ja" : "Nein";
const actionsTd = document.createElement("td");
const qrBtn = document.createElement("button");
qrBtn.textContent = "QR-Code";
qrBtn.className = "secondary";
qrBtn.addEventListener("click", () => showQRCode(p.id));
actionsTd.appendChild(qrBtn);
const dlLink = document.createElement("a");
dlLink.href = "/api/server/" + serverID + "/peer/" + p.id + "/config?host=" + encodeURIComponent(window.location.hostname);
dlLink.textContent = "Config";
dlLink.style.marginLeft = "0.5rem";
actionsTd.appendChild(dlLink);
const delBtn = document.createElement("button");
delBtn.textContent = "Löschen";
delBtn.className = "danger";
delBtn.addEventListener("click", () => deletePeer(p.id));
actionsTd.appendChild(delBtn);
tr.appendChild(nameTd);
tr.appendChild(emailTd);
tr.appendChild(allowedTd);
tr.appendChild(enabledTd);
tr.appendChild(actionsTd);
tbody.appendChild(tr);
}
}
function showQRCode(peerID) {
const modal = document.getElementById("qrcode-modal");
const img = document.getElementById("qrcode-img");
img.src = "/api/server/" + serverID + "/peer/" + peerID + "/qrcode?host=" + encodeURIComponent(window.location.hostname) + "&t=" + Date.now();
modal.classList.remove("hidden");
}
document.getElementById("qrcode-close").addEventListener("click", () => {
document.getElementById("qrcode-modal").classList.add("hidden");
});
async function deletePeer(peerID) {
if (!confirm("Peer wirklich löschen?")) return;
await apiFetch("/api/server/" + serverID + "/peer/" + peerID, { method: "DELETE" });
loadPeers();
}
document.getElementById("btn-start").addEventListener("click", async () => {
await apiFetch("/api/servers/" + serverID + "/start", { method: "POST" });
loadServer();
});
document.getElementById("btn-stop").addEventListener("click", async () => {
await apiFetch("/api/servers/" + serverID + "/stop", { method: "POST" });
loadServer();
});
document.getElementById("btn-reload").addEventListener("click", async () => {
await apiFetch("/api/servers/" + serverID + "/reload", { method: "POST" });
loadServer();
});
document.getElementById("btn-download").addEventListener("click", () => {
window.location.href = "/api/servers/" + serverID + "/config";
});
document.getElementById("peer-form").addEventListener("submit", async (e) => {
e.preventDefault();
const errP = document.getElementById("peer-error");
errP.textContent = "";
const form = e.target;
const body = {
name: form.name.value,
email: form.email.value,
allowed_ips: form.allowed_ips.value,
persistent_keepalive: parseInt(form.persistent_keepalive.value, 10) || 25,
use_preshared_key: form.use_preshared_key.checked,
};
const res = await apiFetch("/api/server/" + serverID + "/peer", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
errP.textContent = data.error || "Peer konnte nicht erstellt werden.";
return;
}
form.reset();
form.persistent_keepalive.value = 25;
loadPeers();
});
loadServer();
loadPeers();
+209
View File
@@ -0,0 +1,209 @@
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
max-width: 960px;
margin: 2rem auto;
padding: 0 1rem;
color: #1c1c1c;
background: #fafafa;
}
h1, h2 {
color: #222;
}
a {
color: #2563eb;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
table {
width: 100%;
border-collapse: collapse;
margin: 1rem 0;
background: #fff;
}
table.details {
width: auto;
min-width: 320px;
}
th, td {
text-align: left;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #e2e2e2;
}
thead th {
background: #f0f0f0;
font-weight: 600;
}
tbody tr:hover {
background: #f7f7f7;
}
button {
cursor: pointer;
background: #2563eb;
color: #fff;
border: none;
border-radius: 4px;
padding: 0.4rem 0.8rem;
margin: 0.15rem;
font-size: 0.9rem;
}
button:hover {
background: #1d4ed8;
}
button.danger {
background: #dc2626;
}
button.danger:hover {
background: #b91c1c;
}
button.secondary {
background: #6b7280;
}
button.secondary:hover {
background: #4b5563;
}
.actions {
margin: 1rem 0;
}
form {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
background: #fff;
padding: 1rem;
border: 1px solid #e2e2e2;
border-radius: 6px;
max-width: 480px;
}
form#login-form {
flex-direction: column;
align-items: stretch;
max-width: 320px;
margin: 3rem auto;
}
input[type="text"],
input[type="email"],
input[type="password"],
input[type="number"] {
padding: 0.4rem 0.6rem;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 0.9rem;
}
label {
font-size: 0.9rem;
}
.error {
color: #dc2626;
font-size: 0.9rem;
min-height: 1.2em;
}
.badge {
display: inline-block;
padding: 0.15rem 0.6rem;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
color: #fff;
}
.badge.up {
background: #16a34a;
}
.badge.down {
background: #dc2626;
}
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
}
.modal.hidden {
display: none;
}
.modal-content {
background: #fff;
padding: 1rem;
border-radius: 6px;
text-align: center;
}
.modal-content img {
display: block;
margin-top: 0.5rem;
max-width: 320px;
}
@media (prefers-color-scheme: dark) {
body {
background: #17181a;
color: #e6e6e6;
}
h1, h2 {
color: #f2f2f2;
}
table, form {
background: #212226;
}
thead th {
background: #2a2b30;
}
th, td {
border-bottom: 1px solid #33343a;
}
tbody tr:hover {
background: #26272c;
}
input {
background: #1b1c1f;
color: #e6e6e6;
border: 1px solid #3a3b41;
}
.modal-content {
background: #212226;
}
}
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>wireguard-ui-multi — Dashboard</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<h1>WireGuard Server</h1>
<table id="servers">
<thead>
<tr><th>Name</th><th>Interface</th><th>Port</th><th>Status</th><th>Aktionen</th></tr>
</thead>
<tbody></tbody>
</table>
<button id="new-server">Neuer Server</button>
<script src="/static/app.js"></script>
</body>
</html>
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>wireguard-ui-multi — Login</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<form id="login-form">
<h1>Anmelden</h1>
<input type="text" name="username" placeholder="Benutzername" required>
<input type="password" name="password" placeholder="Passwort" required>
<button type="submit">Login</button>
<p id="login-error" class="error"></p>
</form>
<script src="/static/login.js"></script>
</body>
</html>
+60
View File
@@ -0,0 +1,60 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>wireguard-ui-multi — Server</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<p><a href="/">&larr; Zurück zum Dashboard</a></p>
<h1 id="server-name">Server</h1>
<p id="server-error" class="error"></p>
<table class="details">
<tbody>
<tr><th>Interface</th><td id="d-interface"></td></tr>
<tr><th>Port</th><td id="d-port"></td></tr>
<tr><th>Address Range</th><td id="d-address"></td></tr>
<tr><th>DNS</th><td id="d-dns"></td></tr>
<tr><th>MTU</th><td id="d-mtu"></td></tr>
<tr><th>Status</th><td id="d-status"></td></tr>
</tbody>
</table>
<div class="actions">
<button id="btn-start">Start</button>
<button id="btn-stop">Stop</button>
<button id="btn-reload">Neu laden</button>
<button id="btn-download">Config herunterladen</button>
</div>
<h2>Peers</h2>
<table id="peers">
<thead>
<tr><th>Name</th><th>Email</th><th>Allowed IPs</th><th>Aktiv</th><th>Aktionen</th></tr>
</thead>
<tbody></tbody>
</table>
<h2>Neuen Peer hinzufügen</h2>
<form id="peer-form">
<input type="text" name="name" placeholder="Name" required>
<input type="email" name="email" placeholder="Email">
<input type="text" name="allowed_ips" placeholder="Allowed IPs, z.B. 10.20.22.5/32" required>
<input type="number" name="persistent_keepalive" placeholder="Persistent Keepalive (s)" value="25">
<label><input type="checkbox" name="use_preshared_key"> Preshared Key verwenden</label>
<button type="submit">Peer hinzufügen</button>
<p id="peer-error" class="error"></p>
</form>
<div id="qrcode-modal" class="modal hidden">
<div class="modal-content">
<button id="qrcode-close">Schließen</button>
<img id="qrcode-img" alt="QR Code">
</div>
</div>
<script src="/static/server.js"></script>
</body>
</html>