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
+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();