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:
co-authored by
Claude Sonnet 5
parent
3d6608ef80
commit
3b3ffd8ebf
@@ -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();
|
||||
@@ -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.";
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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="/">← 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>
|
||||
Reference in New Issue
Block a user