Files
webproxyserver-proxyserver/app/static/app.js
T
2026-10-05 13:33:59 +02:00

625 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
// ------------------------------------------------------------------ Grundlagen
const $ = (sel, el = document) => el.querySelector(sel);
const esc = (v) => String(v ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const state = { token: null, me: null, users: [], timer: null };
try { state.token = localStorage.getItem("proxy_token"); } catch (_) { /* ignore */ }
async function api(path, opts = {}) {
const headers = { ...(opts.headers || {}) };
if (state.token) headers.Authorization = "Bearer " + state.token;
let body = opts.body;
if (body && typeof body !== "string") { body = JSON.stringify(body); headers["Content-Type"] = "application/json"; }
const res = await fetch(path, { ...opts, headers, body });
if (res.status === 401 && !path.startsWith("/api/auth/login")) { logout(true); throw new Error("Sitzung abgelaufen"); }
if (opts.raw) { if (!res.ok) throw new Error(res.statusText); return res; }
const data = res.headers.get("content-type")?.includes("json") ? await res.json() : await res.text();
if (!res.ok) {
let msg = data?.detail ?? res.statusText;
if (Array.isArray(msg)) msg = msg.map((d) => `${(d.loc || []).slice(1).join(".")}: ${d.msg}`).join("; ");
throw new Error(msg);
}
return data;
}
function toast(msg, err = false) {
const t = $("#toast");
t.textContent = msg;
t.className = "toast" + (err ? " err" : "");
t.hidden = false;
clearTimeout(toast._t);
toast._t = setTimeout(() => (t.hidden = true), err ? 6000 : 2800);
}
const fmtBytes = (n) => {
n = Number(n) || 0;
const u = ["B", "KB", "MB", "GB", "TB"];
let i = 0;
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
return (i ? n.toFixed(n < 10 ? 1 : 0) : n) + " " + u[i];
};
const fmtNum = (n) => Number(n || 0).toLocaleString("de-DE");
const fmtTime = (ts) => ts ? new Date(ts * 1000).toLocaleString("de-DE") : "–";
const fmtDur = (s) => { s = Number(s) || 0; if (s < 60) return s.toFixed(1) + " s"; if (s < 3600) return Math.floor(s / 60) + " min"; return (s / 3600).toFixed(1) + " h"; };
const ago = (ts) => { if (!ts) return "nie"; const s = Date.now() / 1000 - ts; if (s < 60) return "gerade eben"; if (s < 3600) return `vor ${Math.floor(s / 60)} min`; if (s < 86400) return `vor ${Math.floor(s / 3600)} h`; return `vor ${Math.floor(s / 86400)} Tagen`; };
const STATUS = {
ok: ["ok", "OK"], blocked: ["bad", "Gesperrt"], error: ["warn", "Fehler"], auth_failed: ["bad", "Login fehlgeschl."],
};
const statusBadge = (s) => { const [c, l] = STATUS[s] || ["", s]; return `<span class="badge ${c}">${esc(l)}</span>`; };
const PROTO = { http: "HTTP", https: "HTTPS", connect: "CONNECT", socks: "SOCKS5", "socks-udp": "SOCKS5 UDP", "fwd-tcp": "Weiterl. TCP", "fwd-udp": "Weiterl. UDP" };
const protoBadge = (p) => `<span class="badge info">${esc(PROTO[p] || p || "–")}</span>`;
const policyLabel = { inherit: "Wie global", allow: "Alles erlauben (Blacklist)", deny: "Alles sperren (Whitelist)" };
// ------------------------------------------------------------------ Modal
const KEEP_OPEN = Symbol("keep");
// onSubmit(data) darf werfen (Fehler wird angezeigt) oder KEEP_OPEN liefern (Dialog bleibt offen).
function modal(title, fieldsHtml, { submit = "Speichern", danger = false, onSubmit = null } = {}) {
const dlg = $("#modal");
const form = $("#modal-form");
form.innerHTML = `<h2>${esc(title)}</h2>${fieldsHtml}
<p class="error" id="modal-error"></p>
<div class="modal-actions">
<button class="btn" value="cancel" formnovalidate>${submit ? "Abbrechen" : "Schließen"}</button>
${submit ? `<button class="btn ${danger ? "danger" : "primary"}" value="ok">${esc(submit)}</button>` : ""}
</div>`;
dlg.showModal();
return new Promise((resolve) => {
let busy = false;
form.onsubmit = async (e) => {
if (e.submitter?.value !== "ok") return; // Abbrechen schließt per method=dialog
e.preventDefault();
if (busy) return;
const data = {};
for (const el of form.elements) if (el.name) data[el.name] = el.type === "checkbox" ? el.checked : el.value;
if (!onSubmit) { resolve(data); dlg.close(); return; }
busy = true;
$("#modal-error").textContent = "";
try {
const out = await onSubmit(data);
if (out !== KEEP_OPEN) { resolve(out ?? true); dlg.close(); }
} catch (err) { $("#modal-error").textContent = err.message; }
busy = false;
};
dlg.onclose = () => resolve(null);
});
}
const modalLoop = (title, html, action, opts = {}) => modal(title, html, { ...opts, onSubmit: action });
const confirmBox = (text, submit = "Löschen") => modal("Bitte bestätigen", `<p>${esc(text)}</p>`, { submit, danger: true });
// ------------------------------------------------------------------ Navigation
const PAGES_ADMIN = [
["dashboard", "◧", "Übersicht"], ["users", "☺", "Benutzer"], ["rules", "⛨", "Regeln"],
["forwards", "⇆", "Weiterleitungen"], ["history", "☰", "Verlauf"], ["connections", "⚡", "Live-Verbindungen"],
["settings", "⚙", "Einstellungen"], ["api", "⌘", "API"],
];
const PAGES_USER = [["myhistory", "☰", "Mein Verlauf"], ["help", "?", "Einrichtung"]];
function renderNav() {
const pages = state.me.is_admin ? PAGES_ADMIN : PAGES_USER;
const current = location.hash.slice(1).split("?")[0] || pages[0][0];
$("#nav").innerHTML = pages.map(([id, ico, label]) =>
`<a href="#${id}" class="${id === current ? "active" : ""}"><span class="ico">${ico}</span>${label}</a>`).join("");
}
async function route() {
clearInterval(state.timer);
const pages = state.me.is_admin ? PAGES_ADMIN : PAGES_USER;
const [page, query] = location.hash.slice(1).split("?");
const id = pages.some((p) => p[0] === page) ? page : pages[0][0];
renderNav();
const params = new URLSearchParams(query || "");
try { await VIEWS[id]($("#main"), params); }
catch (e) { $("#main").innerHTML = `<div class="card error">${esc(e.message)}</div>`; }
}
const head = (title, sub = "", actions = "") =>
`<div class="page-head"><div><h1>${esc(title)}</h1>${sub ? `<p>${sub}</p>` : ""}</div><div class="toolbar" style="margin:0">${actions}</div></div>`;
async function loadUsers() { state.users = await api("/api/users"); return state.users; }
const userOptions = (selected, withGlobal = true) =>
(withGlobal ? `<option value="">Global (alle Benutzer)</option>` : "") +
state.users.map((u) => `<option value="${u.id}" ${String(selected) === String(u.id) ? "selected" : ""}>${esc(u.username)}</option>`).join("");
// ------------------------------------------------------------------ Diagramm
function barChart(el, points, bucket) {
if (!points.length) { el.innerHTML = `<div class="empty">Noch keine Daten im Zeitraum</div>`; return; }
const W = Math.max(el.clientWidth, 300), H = 180, padL = 40, padB = 22, padT = 8;
const max = Math.max(...points.map((p) => p.n), 1);
const step = (W - padL) / points.length;
const bw = Math.max(Math.min(step - 2, 28), 2);
const y = (v) => padT + (H - padT - padB) * (1 - v / max);
const ticks = [0, Math.round(max / 2), max];
const fmtLabel = (ts) => bucket >= 86400
? new Date(ts * 1000).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit" })
: new Date(ts * 1000).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
const every = Math.ceil(points.length / Math.floor((W - padL) / 60));
let svg = `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="Anfragen pro ${bucket >= 86400 ? "Tag" : "Stunde"}">`;
for (const t of ticks) svg += `<line class="grid-line" x1="${padL}" x2="${W}" y1="${y(t)}" y2="${y(t)}"/><text class="axis" x="${padL - 6}" y="${y(t) + 4}" text-anchor="end">${fmtNum(t)}</text>`;
points.forEach((p, i) => {
const x = padL + i * step + (step - bw) / 2;
const top = y(p.n), h = Math.max(H - padB - top, p.n ? 1 : 0);
const r = Math.min(4, bw / 2, h);
// oben abgerundet, unten an der Grundlinie gerade
const path = h > 0 ? `M${x},${top + h} V${top + r} Q${x},${top} ${x + r},${top} H${x + bw - r} Q${x + bw},${top} ${x + bw},${top + r} V${top + h} Z` : "";
svg += `<path class="bar" data-i="${i}" d="${path}"/>`;
svg += `<rect data-i="${i}" x="${padL + i * step}" y="${padT}" width="${step}" height="${H - padT - padB}" fill="transparent"/>`;
if (i % every === 0) svg += `<text class="axis" x="${x + bw / 2}" y="${H - 6}" text-anchor="middle">${fmtLabel(p.b)}</text>`;
});
svg += "</svg>";
el.innerHTML = svg + `<div class="chart-tip" hidden></div>`;
const tip = $(".chart-tip", el);
el.querySelector("svg").addEventListener("mousemove", (e) => {
const i = e.target.dataset?.i;
el.querySelectorAll(".bar.hover").forEach((b) => b.classList.remove("hover"));
if (i === undefined) { tip.hidden = true; return; }
const p = points[i];
el.querySelector(`.bar[data-i="${i}"]`)?.classList.add("hover");
const rect = el.getBoundingClientRect();
tip.hidden = false;
tip.style.left = (e.clientX - rect.left) + "px";
tip.style.top = (e.clientY - rect.top) + "px";
tip.innerHTML = `<b>${esc(bucket >= 86400 ? new Date(p.b * 1000).toLocaleDateString("de-DE") : fmtTime(p.b))}</b><br>${fmtNum(p.n)} Anfragen · ${fmtNum(p.blocked)} gesperrt`;
});
el.querySelector("svg").addEventListener("mouseleave", () => { tip.hidden = true; el.querySelectorAll(".bar.hover").forEach((b) => b.classList.remove("hover")); });
}
function fillBuckets(timeline, hours, bucket) {
const map = new Map(timeline.map((t) => [t.b, t]));
const end = Math.floor(Date.now() / 1000 / bucket) * bucket;
const n = Math.ceil(hours * 3600 / bucket);
const out = [];
for (let i = n - 1; i >= 0; i--) { const b = end - i * bucket; out.push(map.get(b) || { b, n: 0, blocked: 0 }); }
return out;
}
// ------------------------------------------------------------------ Seiten
const VIEWS = {};
VIEWS.dashboard = async (main, params) => {
const hours = Number(params.get("h") || 24);
const s = await api(`/api/stats?hours=${hours}`);
const t = s.totals;
const rangeSel = `<select id="range">${[[24, "Letzte 24 Stunden"], [168, "Letzte 7 Tage"], [720, "Letzte 30 Tage"]].map(([v, l]) => `<option value="${v}" ${v === hours ? "selected" : ""}>${l}</option>`).join("")}</select>`;
const blockedPct = t.total ? ((t.blocked / t.total) * 100).toFixed(1) : "0";
const list = (rows, cols) => rows.length ? `<div class="table-wrap"><table class="mini"><tbody>${rows.map(cols).join("")}</tbody></table></div>` : `<div class="empty">Keine Daten</div>`;
main.innerHTML = head("Übersicht", "Aktivität des Proxys", rangeSel) + `
<div class="stack">
<div class="grid tiles">
<div class="card tile"><div class="label">Anfragen</div><div class="value">${fmtNum(t.total)}</div><div class="sub">${fmtNum(t.users)} aktive Benutzer</div></div>
<div class="card tile"><div class="label">Gesperrt</div><div class="value">${fmtNum(t.blocked)}</div><div class="sub">${blockedPct} % aller Anfragen</div></div>
<div class="card tile"><div class="label">Datenvolumen</div><div class="value">${fmtBytes(t.up + t.down)}</div><div class="sub">↑ ${fmtBytes(t.up)} · ↓ ${fmtBytes(t.down)}</div></div>
<div class="card tile"><div class="label">Live-Verbindungen</div><div class="value">${fmtNum(s.active_connections)}</div><div class="sub">${fmtNum(s.user_count)} Benutzer angelegt</div></div>
<div class="card tile"><div class="label">Fehler / Fehllogins</div><div class="value">${fmtNum(t.errors)} / ${fmtNum(t.auth_failed)}</div><div class="sub">Verbindungsfehler / Anmeldung</div></div>
</div>
<div class="card"><h2>Anfragen pro ${s.bucket >= 86400 ? "Tag" : "Stunde"}</h2><div class="chart" id="chart"></div></div>
<div class="grid two">
<div class="card"><h2>Meistbesuchte Ziele</h2>${list(s.top_hosts, (r) => `<tr><td class="wrap">${esc(r.host)}</td><td class="num">${fmtNum(r.n)}</td><td class="num">${fmtBytes(r.bytes)}</td></tr>`)}</div>
<div class="card"><h2>Meistgesperrte Ziele</h2>${list(s.top_blocked, (r) => `<tr><td class="wrap">${esc(r.host)}</td><td class="num">${fmtNum(r.n)}</td></tr>`)}</div>
<div class="card"><h2>Benutzer nach Datenvolumen</h2>${list(s.top_users, (r) => `<tr><td><a href="#history?username=${encodeURIComponent(r.username)}">${esc(r.username)}</a></td><td class="num">${fmtNum(r.n)}</td><td class="num">${fmtBytes(r.bytes)}</td></tr>`)}</div>
</div>
</div>`;
$("#range").onchange = (e) => (location.hash = `dashboard?h=${e.target.value}`);
barChart($("#chart"), fillBuckets(s.timeline, hours, s.bucket), s.bucket);
};
// ---- Benutzer
function userForm(u = {}) {
const pol = u.policy || "inherit";
return `<div class="form-grid">
<label>Benutzername<input name="username" value="${esc(u.username)}" required pattern="[A-Za-z0-9._@\\-]+"></label>
<label>Passwort${u.id ? ' <span class="hint">(leer = unverändert)</span>' : ""}<input name="password" type="password" minlength="6" ${u.id ? "" : "required"} autocomplete="new-password"></label>
</div>
<label>Standardrichtlinie
<select name="policy">${Object.entries(policyLabel).map(([k, l]) => `<option value="${k}" ${k === pol ? "selected" : ""}>${l}</option>`).join("")}</select>
<span class="hint">Greift, wenn keine Regel passt. „Alles sperren“ + Erlaubt-Regeln = Whitelist für diesen Benutzer.</span>
</label>
<label>Kommentar<input name="comment" value="${esc(u.comment)}"></label>
<label class="check"><input type="checkbox" name="enabled" ${u.enabled !== 0 && u.enabled !== false ? "checked" : ""}> Aktiv (darf den Proxy benutzen)</label>
<label class="check"><input type="checkbox" name="is_admin" ${u.is_admin ? "checked" : ""}> Administrator (Zugriff auf diese Oberfläche)</label>`;
}
VIEWS.users = async (main) => {
const users = await loadUsers();
main.innerHTML = head("Benutzer", "Wer den Proxy benutzen darf", `<button class="btn primary" id="add">+ Benutzer anlegen</button>`) +
`<div class="table-wrap"><table><thead><tr><th>Benutzer</th><th>Status</th><th>Richtlinie</th><th class="num">Eigene Regeln</th><th>Zuletzt aktiv</th><th>Kommentar</th><th></th></tr></thead><tbody>
${users.map((u) => `<tr>
<td><b>${esc(u.username)}</b> ${u.is_admin ? '<span class="badge info">Admin</span>' : ""}</td>
<td>${u.enabled ? '<span class="badge ok">Aktiv</span>' : '<span class="badge bad">Gesperrt</span>'}</td>
<td>${esc(policyLabel[u.policy])}</td>
<td class="num"><a href="#rules?user=${u.id}">${u.rule_count}</a></td>
<td title="${esc(fmtTime(u.last_seen))}">${ago(u.last_seen)}</td>
<td class="wrap">${esc(u.comment)}</td>
<td class="actions">
<a class="btn small ghost" href="#history?username=${encodeURIComponent(u.username)}">Verlauf</a>
<button class="btn small" data-edit="${u.id}">Bearbeiten</button>
<button class="btn small danger" data-del="${u.id}">Löschen</button>
</td></tr>`).join("") || `<tr><td colspan="7" class="empty">Keine Benutzer</td></tr>`}
</tbody></table></div>`;
$("#add").onclick = async () => {
const ok = await modalLoop("Benutzer anlegen", userForm(), (d) => api("/api/users", { method: "POST", body: d }));
if (ok) { toast("Benutzer angelegt"); route(); }
};
main.onclick = async (e) => {
const edit = e.target.dataset.edit, del = e.target.dataset.del;
if (edit) {
const u = users.find((x) => x.id == edit);
const ok = await modalLoop(`Benutzer „${u.username}“ bearbeiten`, userForm(u), (d) => {
if (!d.password) delete d.password;
return api(`/api/users/${edit}`, { method: "PUT", body: d });
});
if (ok) { toast("Gespeichert"); route(); }
}
if (del) {
const u = users.find((x) => x.id == del);
if (await confirmBox(`Benutzer „${u.username}“ inkl. seiner Regeln löschen?`)) {
try { await api(`/api/users/${del}`, { method: "DELETE" }); toast("Gelöscht"); route(); } catch (err) { toast(err.message, true); }
}
}
};
};
// ---- Regeln
function ruleForm(r = {}) {
return `<label>Gilt für<select name="user_id">${userOptions(r.user_id ?? "")}</select></label>
<label>Aktion<select name="action">
<option value="deny" ${r.action !== "allow" ? "selected" : ""}>Sperren (Blacklist)</option>
<option value="allow" ${r.action === "allow" ? "selected" : ""}>Erlauben (Whitelist)</option></select></label>
<div class="form-grid">
<label>Host / Domain / Netz<input name="host" value="${esc(r.host)}" placeholder="z. B. facebook.com">
<span class="hint">leer = alle · example.com (inkl. Subdomains) · *.example.com · 10.0.0.0/8 · re:regex</span></label>
<label>Ports<input name="ports" value="${esc(r.ports)}" placeholder="z. B. 25 oder 6000-7000,8080">
<span class="hint">leer = alle Ports</span></label>
</div>
<label>Kommentar<input name="comment" value="${esc(r.comment)}"></label>
<label class="check"><input type="checkbox" name="enabled" ${r.enabled !== 0 ? "checked" : ""}> Regel aktiv</label>`;
}
const ruleBody = (d) => ({ ...d, user_id: d.user_id ? Number(d.user_id) : null });
VIEWS.rules = async (main, params) => {
await loadUsers();
const userFilter = params.get("user") || "";
const rules = await api(userFilter ? `/api/rules?user_id=${userFilter}` : "/api/rules");
const filterSel = `<select id="rule-filter"><option value="">Alle Regeln</option>${state.users.map((u) => `<option value="${u.id}" ${userFilter == u.id ? "selected" : ""}>nur ${esc(u.username)}</option>`).join("")}</select>`;
main.innerHTML = head("Regeln", "Black- und Whitelist für Domains, IPs und Ports",
`${filterSel}<button class="btn" id="test">Regel testen</button><button class="btn" id="bulk">Liste importieren</button><button class="btn primary" id="add">+ Regel</button>`) +
`<div class="card" style="margin-bottom:16px"><b>So wird ausgewertet:</b> Benutzer-Sperren → Benutzer-Erlaubnisse → globale Sperren → globale Erlaubnisse → Standardrichtlinie.
Benutzerregeln haben also Vorrang vor globalen Regeln. Für eine reine <b>Whitelist</b> die Standardrichtlinie (global oder pro Benutzer) auf „Alles sperren“ stellen und Erlaubt-Regeln anlegen.</div>
<div class="table-wrap"><table><thead><tr><th>Gilt für</th><th>Aktion</th><th>Host / Netz</th><th>Ports</th><th>Kommentar</th><th>Aktiv</th><th></th></tr></thead><tbody>
${rules.map((r) => `<tr>
<td>${r.user_id ? `<span class="badge info">${esc(r.username)}</span>` : '<span class="badge">Global</span>'}</td>
<td>${r.action === "deny" ? '<span class="badge bad">Sperren</span>' : '<span class="badge ok">Erlauben</span>'}</td>
<td class="wrap mono">${esc(r.host || "* (alle)")}</td>
<td class="mono">${esc(r.ports || "alle")}</td>
<td class="wrap">${esc(r.comment)}</td>
<td>${r.enabled ? "✓" : "–"}</td>
<td class="actions"><button class="btn small" data-edit="${r.id}">Bearbeiten</button> <button class="btn small danger" data-del="${r.id}">Löschen</button></td>
</tr>`).join("") || `<tr><td colspan="7" class="empty">Keine Regeln – alles wird nach der Standardrichtlinie behandelt.</td></tr>`}
</tbody></table></div>`;
$("#rule-filter").onchange = (e) => (location.hash = e.target.value ? `rules?user=${e.target.value}` : "rules");
$("#add").onclick = async () => {
const ok = await modalLoop("Regel anlegen", ruleForm({ user_id: userFilter || null }), (d) => api("/api/rules", { method: "POST", body: ruleBody(d) }));
if (ok) { toast("Regel angelegt"); route(); }
};
$("#bulk").onclick = async () => {
const html = `<label>Gilt für<select name="user_id">${userOptions(userFilter)}</select></label>
<label>Aktion<select name="action"><option value="deny">Sperren (Blacklist)</option><option value="allow">Erlauben (Whitelist)</option></select></label>
<label>Hosts (einer pro Zeile)<textarea name="hosts" placeholder="facebook.com&#10;*.tiktok.com&#10;0.0.0.0 ads.example.com" required></textarea>
<span class="hint">Auch Hosts-Datei-Format und #-Kommentare werden verstanden.</span></label>
<label>Ports<input name="ports" placeholder="leer = alle"></label>
<label>Kommentar<input name="comment" placeholder="z. B. Social Media"></label>`;
const out = await modalLoop("Liste importieren", html, (d) => api("/api/rules/bulk", { method: "POST", body: { ...ruleBody(d), hosts: d.hosts.split(/\r?\n/) } }), { submit: "Importieren" });
if (out) { toast(`${out.added} Regeln importiert, ${out.skipped} übersprungen`); route(); }
};
$("#test").onclick = async () => {
const html = `<label>Benutzer<select name="username"><option value="">(anonym / nur globale Regeln)</option>${state.users.map((u) => `<option>${esc(u.username)}</option>`).join("")}</select></label>
<div class="form-grid"><label>Host<input name="host" required placeholder="www.example.com"></label><label>Port<input name="port" type="number" value="443" min="1" max="65535"></label></div>
<div id="test-result"></div>`;
await modal("Regel testen", html, { submit: "Prüfen", onSubmit: async (d) => {
const res = await api("/api/rules/test", { method: "POST", body: { username: d.username || null, host: d.host, port: Number(d.port) } });
$("#test-result").innerHTML = `<div class="card">${res.allowed ? '<span class="badge ok">Erlaubt</span>' : '<span class="badge bad">Gesperrt</span>'} ${esc(res.reason)}
${res.addresses.length ? `<div class="hint" style="margin-top:8px">${res.addresses.map((a) => `${esc(a.ip)}: ${a.allowed ? "erlaubt" : "gesperrt – " + esc(a.reason)}`).join("<br>")}</div>` : ""}
${res.dns_error ? `<div class="hint">DNS: ${esc(res.dns_error)}</div>` : ""}</div>`;
return KEEP_OPEN;
} });
};
main.onclick = async (e) => {
const edit = e.target.dataset.edit, del = e.target.dataset.del;
if (edit) {
const rule = rules.find((x) => x.id == edit);
const ok = await modalLoop("Regel bearbeiten", ruleForm(rule), (d) => api(`/api/rules/${edit}`, { method: "PUT", body: ruleBody(d) }));
if (ok) { toast("Gespeichert"); route(); }
}
if (del && await confirmBox("Regel löschen?")) {
await api(`/api/rules/${del}`, { method: "DELETE" }); toast("Gelöscht"); route();
}
};
};
// ---- Weiterleitungen
function forwardForm(f = {}) {
return `<label>Name<input name="name" value="${esc(f.name)}" required placeholder="z. B. WireGuard Büro"></label>
<div class="form-grid">
<label>Protokoll<select name="protocol"><option value="tcp" ${f.protocol !== "udp" ? "selected" : ""}>TCP</option><option value="udp" ${f.protocol === "udp" ? "selected" : ""}>UDP</option></select></label>
<label>Lokaler Port<input name="listen_port" type="number" min="1" max="65535" value="${esc(f.listen_port)}" required></label>
</div>
<div class="form-grid">
<label>Ziel-Host<input name="target_host" value="${esc(f.target_host)}" required placeholder="vpn.example.com"></label>
<label>Ziel-Port<input name="target_port" type="number" min="1" max="65535" value="${esc(f.target_port)}" required></label>
</div>
<label>Erlaubte Quell-Netze<input name="allowed_sources" value="${esc(f.allowed_sources)}" placeholder="z. B. 192.168.1.0/24 10.8.0.5">
<span class="hint">Leer = jeder darf (das Ziel ist fest, es ist also kein offener Proxy). Globale Regeln gelten zusätzlich.</span></label>
<label>Kommentar<input name="comment" value="${esc(f.comment)}"></label>
<label class="check"><input type="checkbox" name="enabled" ${f.enabled !== 0 ? "checked" : ""}> Aktiv</label>`;
}
const fwdBody = (d) => ({ ...d, listen_port: Number(d.listen_port), target_port: Number(d.target_port) });
VIEWS.forwards = async (main) => {
const { items, ports_hint } = await api("/api/forwards");
main.innerHTML = head("Weiterleitungen", `Für Programme ohne Proxy-Unterstützung (WireGuard, Outlook-Mailkonten, Geräte …): Port am Proxy → festes Ziel. Im Docker-Setup freigegebene Ports: <b>${esc(ports_hint)}</b> (TCP & UDP).`,
`<button class="btn primary" id="add">+ Weiterleitung</button>`) +
`<div class="table-wrap"><table><thead><tr><th>Name</th><th>Proto</th><th>Proxy-Port</th><th>Ziel</th><th>Erlaubte Quellen</th><th>Status</th><th></th></tr></thead><tbody>
${items.map((f) => `<tr>
<td><b>${esc(f.name)}</b>${f.comment ? `<div class="hint">${esc(f.comment)}</div>` : ""}</td>
<td><span class="badge">${f.protocol.toUpperCase()}</span></td>
<td class="mono">${f.listen_port}</td>
<td class="mono">${esc(f.target_host)}:${f.target_port}</td>
<td class="mono wrap">${esc(f.allowed_sources || "alle")}</td>
<td>${f.status === "aktiv" ? '<span class="badge ok">aktiv</span>' : f.status === "deaktiviert" ? '<span class="badge">deaktiviert</span>' : `<span class="badge bad">${esc(f.status)}</span>`}</td>
<td class="actions"><button class="btn small" data-edit="${f.id}">Bearbeiten</button> <button class="btn small danger" data-del="${f.id}">Löschen</button></td>
</tr>`).join("") || `<tr><td colspan="7" class="empty">Keine Weiterleitungen. Beispiel: WireGuard → UDP, Port 40000 → vpn.firma.de:51820</td></tr>`}
</tbody></table></div>`;
$("#add").onclick = async () => {
const ok = await modalLoop("Weiterleitung anlegen", forwardForm(), (d) => api("/api/forwards", { method: "POST", body: fwdBody(d) }));
if (ok) { toast("Weiterleitung angelegt"); route(); }
};
main.onclick = async (e) => {
const edit = e.target.dataset.edit, del = e.target.dataset.del;
if (edit) {
const f = items.find((x) => x.id == edit);
const ok = await modalLoop("Weiterleitung bearbeiten", forwardForm(f), (d) => api(`/api/forwards/${edit}`, { method: "PUT", body: fwdBody(d) }));
if (ok) { toast("Gespeichert"); route(); }
}
if (del && await confirmBox("Weiterleitung löschen?")) {
await api(`/api/forwards/${del}`, { method: "DELETE" }); toast("Gelöscht"); route();
}
};
};
// ---- Verlauf
function historyView(admin) {
return async (main, params) => {
if (admin) await loadUsers();
const f = Object.fromEntries(params.entries());
const limit = 100, offset = Number(f.offset || 0);
const q = new URLSearchParams();
for (const k of ["username", "q", "status", "proto"]) if (f[k]) q.set(k, f[k]);
if (f.from) q.set("since", new Date(f.from).getTime() / 1000);
if (f.to) q.set("until", new Date(f.to).getTime() / 1000);
const base = admin ? "/api/history" : "/api/me/history";
const data = await api(`${base}?${q}&limit=${limit}&offset=${offset}`);
const sel = (name, opts) => `<select name="${name}"><option value="">${name === "username" ? "Alle Benutzer" : name === "status" ? "Alle Status" : "Alle Protokolle"}</option>${opts.map(([v, l]) => `<option value="${esc(v)}" ${f[name] === v ? "selected" : ""}>${esc(l)}</option>`).join("")}</select>`;
main.innerHTML = head(admin ? "Verlauf" : "Mein Verlauf", "Wer hat wann was aufgerufen",
admin ? `<button class="btn" id="csv">CSV-Export</button><button class="btn danger" id="clear">Verlauf leeren</button>` : "") +
`<form class="toolbar" id="filter">
${admin ? sel("username", state.users.map((u) => [u.username, u.username])) : ""}
<input name="q" placeholder="Suche Host, URL, IP …" value="${esc(f.q)}">
${sel("status", Object.entries(STATUS).map(([k, v]) => [k, v[1]]))}
${admin ? sel("proto", Object.entries(PROTO)) : ""}
<label>Von<input type="datetime-local" name="from" value="${esc(f.from)}"></label>
<label>Bis<input type="datetime-local" name="to" value="${esc(f.to)}"></label>
<button class="btn primary">Filtern</button>
<a class="btn ghost" href="#${admin ? "history" : "myhistory"}">Zurücksetzen</a>
</form>
<div class="table-wrap"><table><thead><tr><th>Zeit</th>${admin ? "<th>Benutzer</th>" : ""}<th>Client</th><th>Protokoll</th><th>Ziel</th><th>Status</th><th class="num">↑</th><th class="num">↓</th><th class="num">Dauer</th></tr></thead><tbody>
${data.items.map((r) => `<tr>
<td style="white-space:nowrap">${fmtTime(r.ts)}</td>
${admin ? `<td>${r.username ? `<a href="#history?username=${encodeURIComponent(r.username)}">${esc(r.username)}</a>` : '<span class="hint">–</span>'}</td>` : ""}
<td class="mono">${esc(r.client_ip)}</td>
<td>${protoBadge(r.proto)}</td>
<td class="wrap"><span class="mono">${esc(r.method && r.proto === "http" ? r.method + " " : "")}${esc(r.url || (r.host ? `${r.host}:${r.port}` : ""))}</span>${r.detail ? `<div class="hint">${esc(r.detail)}</div>` : ""}</td>
<td>${statusBadge(r.status)}</td>
<td class="num">${fmtBytes(r.bytes_up)}</td><td class="num">${fmtBytes(r.bytes_down)}</td><td class="num">${fmtDur(r.duration)}</td>
</tr>`).join("") || `<tr><td colspan="9" class="empty">Keine Einträge</td></tr>`}
</tbody></table></div>
<div class="pager">${fmtNum(data.total)} Einträge · Seite ${Math.floor(offset / limit) + 1} von ${Math.max(1, Math.ceil(data.total / limit))}
<button class="btn small" id="prev" ${offset <= 0 ? "disabled" : ""}>‹ Zurück</button>
<button class="btn small" id="next" ${offset + limit >= data.total ? "disabled" : ""}>Weiter ›</button></div>`;
const go = (extra = {}) => {
const p = new URLSearchParams();
const form = new FormData($("#filter"));
for (const [k, v] of form.entries()) if (v) p.set(k, v);
for (const [k, v] of Object.entries(extra)) p.set(k, v);
location.hash = `${admin ? "history" : "myhistory"}?${p}`;
};
$("#filter").onsubmit = (e) => { e.preventDefault(); go(); };
$("#prev").onclick = () => go({ offset: Math.max(0, offset - limit) });
$("#next").onclick = () => go({ offset: offset + limit });
if (admin) {
$("#csv").onclick = async () => {
try {
const res = await api(`/api/history/export.csv?${q}`, { raw: true });
const url = URL.createObjectURL(await res.blob());
const a = Object.assign(document.createElement("a"), { href: url, download: "proxy-verlauf.csv" });
document.body.append(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 5000);
} catch (e) { toast(e.message, true); }
};
$("#clear").onclick = async () => {
if (await confirmBox("Den kompletten Verlauf unwiderruflich löschen?", "Alles löschen")) {
const r = await api("/api/history", { method: "DELETE" }); toast(`${fmtNum(r.deleted)} Einträge gelöscht`); route();
}
};
}
};
}
VIEWS.history = historyView(true);
VIEWS.myhistory = historyView(false);
// ---- Live-Verbindungen
VIEWS.connections = async (main) => {
main.innerHTML = head("Live-Verbindungen", "Aktualisiert sich alle 3 Sekunden") + `<div id="conns"></div>`;
const draw = async () => {
const list = await api("/api/connections");
const el = $("#conns");
if (!el) return;
el.innerHTML = `<div class="table-wrap"><table><thead><tr><th>Seit</th><th>Benutzer</th><th>Client</th><th>Protokoll</th><th>Ziel</th><th class="num">↑</th><th class="num">↓</th><th></th></tr></thead><tbody>
${list.map((c) => `<tr><td>${fmtDur(Date.now() / 1000 - c.started)}</td><td>${esc(c.username || "–")}</td><td class="mono">${esc(c.client_ip)}</td>
<td>${protoBadge(c.proto)}</td><td class="wrap mono">${esc(c.url || `${c.host}:${c.port}`)}</td>
<td class="num">${fmtBytes(c.bytes_up)}</td><td class="num">${fmtBytes(c.bytes_down)}</td>
<td class="actions"><button class="btn small danger" data-kill="${c.id}">Trennen</button></td></tr>`).join("") || `<tr><td colspan="8" class="empty">Keine aktiven Verbindungen</td></tr>`}
</tbody></table></div>`;
};
await draw();
state.timer = setInterval(() => draw().catch(() => {}), 3000);
main.onclick = async (e) => {
if (e.target.dataset.kill) { await api(`/api/connections/${e.target.dataset.kill}`, { method: "DELETE" }).catch((x) => toast(x.message, true)); draw(); }
};
};
// ---- Einstellungen
function setupHelp(s) {
const host = s.public_host || location.hostname;
return `<div class="card"><h2>Clients einrichten</h2>
<dl class="kv">
<dt>HTTP/HTTPS-Proxy</dt><dd class="mono">${esc(host)}:${s.ports.http}</dd>
<dt>SOCKS5-Proxy</dt><dd class="mono">${esc(host)}:${s.ports.socks}</dd>
<dt>Auto-Konfiguration (PAC)</dt><dd class="mono">http://${esc(host)}:${s.ports.web}/proxy.pac</dd>
<dt>SOCKS5 UDP-Ports</dt><dd class="mono">${esc(s.ports.socks_udp)}</dd>
<dt>Weiterleitungs-Ports</dt><dd class="mono">${esc(s.ports.forwards)}</dd>
</dl>
<p class="hint" style="margin-top:12px">Beide Proxy-Ports verstehen HTTP und SOCKS5. Browser: HTTP-Proxy oder PAC-URL eintragen.
Thunderbird &amp; Co. (SMTP, IMAP, POP3): SOCKS5 eintragen. Outlook/Exchange: läuft über HTTPS (Systemproxy);
für SMTP/IMAP in Outlook oder WireGuard eine <a href="#forwards">Weiterleitung</a> anlegen.
Kommandozeile: <code>export https_proxy=http://user:pass@${esc(host)}:${s.ports.http}</code></p></div>`;
}
VIEWS.settings = async (main) => {
const s = await api("/api/settings");
main.innerHTML = head("Einstellungen") + `<div class="stack">
<form class="card stack" id="settings">
<label>Globale Standardrichtlinie
<select name="default_policy">
<option value="allow" ${s.default_policy === "allow" ? "selected" : ""}>Alles erlauben, Sperrliste anwenden (Blacklist-Modus)</option>
<option value="deny" ${s.default_policy === "deny" ? "selected" : ""}>Alles sperren, nur Freigaben erlauben (Whitelist-Modus)</option>
</select></label>
<div class="form-grid">
<label>Verlauf aufbewahren (Tage)<input type="number" name="log_retention_days" min="0" value="${s.log_retention_days}"><span class="hint">0 = unbegrenzt</span></label>
<label>Öffentlicher Hostname<input name="public_host" value="${esc(s.public_host)}" placeholder="proxy.firma.local"><span class="hint">Für PAC-Datei und Anleitung</span></label>
<label>Anmelde-Bereich (Realm)<input name="auth_realm" value="${esc(s.auth_realm)}"></label>
</div>
<label>Netze ohne Anmeldung<input name="anonymous_networks" value="${esc(s.anonymous_networks)}" placeholder="z. B. 192.168.10.0/24">
<span class="hint">Clients aus diesen Netzen dürfen ohne Login surfen (erscheinen als „anonym“, nur globale Regeln). Leer = Anmeldung immer Pflicht.</span></label>
<label class="check"><input type="checkbox" name="log_enabled" ${s.log_enabled ? "checked" : ""}> Verlauf protokollieren</label>
<label class="check"><input type="checkbox" name="block_loopback" ${s.block_loopback ? "checked" : ""}> Zugriff auf Loopback (127.0.0.1, ::1) sperren – schützt diese Oberfläche</label>
<div><button class="btn primary">Speichern</button></div>
</form>
${setupHelp(s)}
</div>`;
$("#settings").onsubmit = async (e) => {
e.preventDefault();
const el = e.target.elements;
try {
await api("/api/settings", { method: "PUT", body: {
default_policy: el.default_policy.value, log_retention_days: Number(el.log_retention_days.value),
public_host: el.public_host.value, auth_realm: el.auth_realm.value, anonymous_networks: el.anonymous_networks.value,
log_enabled: el.log_enabled.checked, block_loopback: el.block_loopback.checked } });
toast("Einstellungen gespeichert");
} catch (err) { toast(err.message, true); }
};
};
VIEWS.help = async (main) => {
const host = location.hostname;
main.innerHTML = head("Einrichtung") + `<div class="card"><dl class="kv">
<dt>HTTP/HTTPS-Proxy</dt><dd class="mono">${esc(host)}:3128</dd>
<dt>SOCKS5</dt><dd class="mono">${esc(host)}:1080</dd>
<dt>PAC-Datei</dt><dd class="mono">http://${esc(host)}:${esc(location.port || 80)}/proxy.pac</dd>
<dt>Anmeldung</dt><dd>Dein Benutzername und Passwort</dd></dl></div>`;
};
// ---- API
VIEWS.api = async (main) => {
const tokens = await api("/api/tokens");
main.innerHTML = head("API", `Alle Funktionen der Oberfläche sind per REST-API nutzbar. <a href="/api/docs" target="_blank">Interaktive API-Doku öffnen ↗</a>`,
`<button class="btn primary" id="add">+ API-Token</button>`) + `<div class="stack">
<div class="table-wrap"><table><thead><tr><th>Name</th><th>Besitzer</th><th>Erstellt</th><th>Läuft ab</th><th>Zuletzt benutzt</th><th></th></tr></thead><tbody>
${tokens.map((t) => `<tr><td><b>${esc(t.name)}</b></td><td>${esc(t.username)}</td><td>${fmtTime(t.created_at)}</td><td>${t.expires_at ? fmtTime(t.expires_at) : "nie"}</td><td>${ago(t.last_used)}</td>
<td class="actions"><button class="btn small danger" data-del="${t.id}">Widerrufen</button></td></tr>`).join("") || `<tr><td colspan="6" class="empty">Noch keine API-Tokens</td></tr>`}
</tbody></table></div>
<div class="card"><h2>Beispiele</h2><pre class="mono" style="white-space:pre-wrap;margin:0"># Benutzer anlegen
curl -X POST ${esc(location.origin)}/api/users \\
-H "Authorization: Bearer &lt;TOKEN&gt;" -H "Content-Type: application/json" \\
-d '{"username":"max","password":"geheim123"}'
# Domain global sperren
curl -X POST ${esc(location.origin)}/api/rules \\
-H "Authorization: Bearer &lt;TOKEN&gt;" -H "Content-Type: application/json" \\
-d '{"action":"deny","host":"facebook.com"}'
# Verlauf eines Benutzers
curl "${esc(location.origin)}/api/history?username=max&amp;limit=50" -H "Authorization: Bearer &lt;TOKEN&gt;"</pre></div></div>`;
$("#add").onclick = async () => {
const out = await modalLoop("API-Token erstellen", `<label>Name<input name="name" required placeholder="z. B. Monitoring"></label>
<label>Gültigkeit (Tage)<input name="days" type="number" min="1" placeholder="leer = unbegrenzt"></label>`,
(d) => api("/api/tokens", { method: "POST", body: { name: d.name, days: d.days ? Number(d.days) : null } }), { submit: "Erstellen" });
if (out) {
await modal("Token erstellt", `<p>Bitte jetzt kopieren – er wird nicht noch einmal angezeigt:</p><div class="secret mono">${esc(out.token)}</div>
<p class="hint">Verwendung: Header <code>Authorization: Bearer &lt;Token&gt;</code> oder <code>X-API-Key: &lt;Token&gt;</code></p>`, { submit: "" });
route();
}
};
main.onclick = async (e) => {
if (e.target.dataset.del && await confirmBox("Token widerrufen?", "Widerrufen")) {
await api(`/api/tokens/${e.target.dataset.del}`, { method: "DELETE" }); toast("Widerrufen"); route();
}
};
};
// ------------------------------------------------------------------ Start / Login
function logout(silent) {
if (!silent && state.token) api("/api/auth/logout", { method: "POST" }).catch(() => {});
state.token = null;
state.me = null;
try { localStorage.removeItem("proxy_token"); } catch (_) { /* ignore */ }
clearInterval(state.timer);
$("#app").hidden = true;
$("#login").hidden = false;
}
async function start() {
if (!state.token) { logout(true); return; }
try { state.me = await api("/api/me"); } catch (_) { return; }
$("#login").hidden = true;
$("#app").hidden = false;
$("#whoami").innerHTML = `Angemeldet als <b>${esc(state.me.username)}</b>`;
route();
}
$("#login-form").onsubmit = async (e) => {
e.preventDefault();
const f = e.target.elements;
$("#login-error").textContent = "";
try {
const r = await api("/api/auth/login", { method: "POST", body: { username: f.username.value, password: f.password.value } });
state.token = r.token;
try { localStorage.setItem("proxy_token", r.token); } catch (_) { /* ignore */ }
f.password.value = "";
start();
} catch (err) { $("#login-error").textContent = err.message; }
};
$("#btn-logout").onclick = () => logout(false);
$("#btn-password").onclick = async () => {
const ok = await modalLoop("Passwort ändern", `<label>Aktuelles Passwort<input type="password" name="old_password" required autocomplete="current-password"></label>
<label>Neues Passwort<input type="password" name="new_password" required minlength="6" autocomplete="new-password"></label>`,
(d) => api("/api/me/password", { method: "POST", body: d }));
if (ok) toast("Passwort geändert");
};
window.addEventListener("hashchange", () => state.me && route());
start();