"use strict";
// ------------------------------------------------------------------ Grundlagen
const $ = (sel, el = document) => el.querySelector(sel);
const esc = (v) => String(v ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 `${esc(l)} `; };
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) => `${esc(PROTO[p] || p || "–")} `;
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 = `
${esc(title)} ${fieldsHtml}
${submit ? "Abbrechen" : "Schließen"}
${submit ? `${esc(submit)} ` : ""}
`;
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", `${esc(text)}
`, { 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]) =>
`${ico} ${label} `).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 = `${esc(e.message)}
`; }
}
const head = (title, sub = "", actions = "") =>
`${esc(title)} ${sub ? `
${sub}
` : ""}
${actions}
`;
async function loadUsers() { state.users = await api("/api/users"); return state.users; }
const userOptions = (selected, withGlobal = true) =>
(withGlobal ? `Global (alle Benutzer) ` : "") +
state.users.map((u) => `${esc(u.username)} `).join("");
// ------------------------------------------------------------------ Diagramm
function barChart(el, points, bucket) {
if (!points.length) { el.innerHTML = `Noch keine Daten im Zeitraum
`; 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 = ``;
for (const t of ticks) svg += `${fmtNum(t)} `;
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 += ` `;
svg += ` `;
if (i % every === 0) svg += `${fmtLabel(p.b)} `;
});
svg += " ";
el.innerHTML = svg + `
`;
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 = `${esc(bucket >= 86400 ? new Date(p.b * 1000).toLocaleDateString("de-DE") : fmtTime(p.b))} ${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 = `${[[24, "Letzte 24 Stunden"], [168, "Letzte 7 Tage"], [720, "Letzte 30 Tage"]].map(([v, l]) => `${l} `).join("")} `;
const blockedPct = t.total ? ((t.blocked / t.total) * 100).toFixed(1) : "0";
const list = (rows, cols) => rows.length ? `${rows.map(cols).join("")}
` : `Keine Daten
`;
main.innerHTML = head("Übersicht", "Aktivität des Proxys", rangeSel) + `
Anfragen
${fmtNum(t.total)}
${fmtNum(t.users)} aktive Benutzer
Gesperrt
${fmtNum(t.blocked)}
${blockedPct} % aller Anfragen
Datenvolumen
${fmtBytes(t.up + t.down)}
↑ ${fmtBytes(t.up)} · ↓ ${fmtBytes(t.down)}
Live-Verbindungen
${fmtNum(s.active_connections)}
${fmtNum(s.user_count)} Benutzer angelegt
Fehler / Fehllogins
${fmtNum(t.errors)} / ${fmtNum(t.auth_failed)}
Verbindungsfehler / Anmeldung
Anfragen pro ${s.bucket >= 86400 ? "Tag" : "Stunde"}
Meistbesuchte Ziele ${list(s.top_hosts, (r) => `${esc(r.host)} ${fmtNum(r.n)} ${fmtBytes(r.bytes)} `)}
Meistgesperrte Ziele ${list(s.top_blocked, (r) => `${esc(r.host)} ${fmtNum(r.n)} `)}
Benutzer nach Datenvolumen ${list(s.top_users, (r) => `
${esc(r.username)} ${fmtNum(r.n)} ${fmtBytes(r.bytes)} `)}
`;
$("#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 `
Benutzername
Passwort${u.id ? ' (leer = unverändert) ' : ""}
Standardrichtlinie
${Object.entries(policyLabel).map(([k, l]) => `${l} `).join("")}
Greift, wenn keine Regel passt. „Alles sperren“ + Erlaubt-Regeln = Whitelist für diesen Benutzer.
Kommentar
Aktiv (darf den Proxy benutzen)
Administrator (Zugriff auf diese Oberfläche) `;
}
VIEWS.users = async (main) => {
const users = await loadUsers();
main.innerHTML = head("Benutzer", "Wer den Proxy benutzen darf", `+ Benutzer anlegen `) +
`Benutzer Status Richtlinie Eigene Regeln Zuletzt aktiv Kommentar
${users.map((u) => `
${esc(u.username)} ${u.is_admin ? 'Admin ' : ""}
${u.enabled ? 'Aktiv ' : 'Gesperrt '}
${esc(policyLabel[u.policy])}
${u.rule_count}
${ago(u.last_seen)}
${esc(u.comment)}
Verlauf
Bearbeiten
Löschen
`).join("") || `Keine Benutzer `}
`;
$("#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 `Gilt für${userOptions(r.user_id ?? "")}
Aktion
Sperren (Blacklist)
Erlauben (Whitelist)
Host / Domain / Netz
leer = alle · example.com (inkl. Subdomains) · *.example.com · 10.0.0.0/8 · re:regex
Ports
leer = alle Ports
Kommentar
Regel aktiv `;
}
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 = `Alle Regeln ${state.users.map((u) => `nur ${esc(u.username)} `).join("")} `;
main.innerHTML = head("Regeln", "Black- und Whitelist für Domains, IPs und Ports",
`${filterSel}Regel testen Liste importieren + Regel `) +
`So wird ausgewertet: Benutzer-Sperren → Benutzer-Erlaubnisse → globale Sperren → globale Erlaubnisse → Standardrichtlinie.
Benutzerregeln haben also Vorrang vor globalen Regeln. Für eine reine Whitelist die Standardrichtlinie (global oder pro Benutzer) auf „Alles sperren“ stellen und Erlaubt-Regeln anlegen.
Gilt für Aktion Host / Netz Ports Kommentar Aktiv
${rules.map((r) => `
${r.user_id ? `${esc(r.username)} ` : 'Global '}
${r.action === "deny" ? 'Sperren ' : 'Erlauben '}
${esc(r.host || "* (alle)")}
${esc(r.ports || "alle")}
${esc(r.comment)}
${r.enabled ? "✓" : "–"}
Bearbeiten Löschen
`).join("") || `Keine Regeln – alles wird nach der Standardrichtlinie behandelt. `}
`;
$("#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 = `Gilt für${userOptions(userFilter)}
AktionSperren (Blacklist) Erlauben (Whitelist)
Hosts (einer pro Zeile)
Auch Hosts-Datei-Format und #-Kommentare werden verstanden.
Ports
Kommentar `;
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 = `Benutzer(anonym / nur globale Regeln) ${state.users.map((u) => `${esc(u.username)} `).join("")}
Host Port
`;
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 = `${res.allowed ? '
Erlaubt ' : '
Gesperrt '} ${esc(res.reason)}
${res.addresses.length ? `
${res.addresses.map((a) => `${esc(a.ip)}: ${a.allowed ? "erlaubt" : "gesperrt – " + esc(a.reason)}`).join(" ")}
` : ""}
${res.dns_error ? `
DNS: ${esc(res.dns_error)}
` : ""}
`;
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 `Name
ProtokollTCP UDP
Lokaler Port
Ziel-Host
Ziel-Port
Erlaubte Quell-Netze
Leer = jeder darf (das Ziel ist fest, es ist also kein offener Proxy). Globale Regeln gelten zusätzlich.
Kommentar
Aktiv `;
}
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: ${esc(ports_hint)} (TCP & UDP).`,
`+ Weiterleitung `) +
`Name Proto Proxy-Port Ziel Erlaubte Quellen Status
${items.map((f) => `
${esc(f.name)} ${f.comment ? `${esc(f.comment)}
` : ""}
${f.protocol.toUpperCase()}
${f.listen_port}
${esc(f.target_host)}:${f.target_port}
${esc(f.allowed_sources || "alle")}
${f.status === "aktiv" ? 'aktiv ' : f.status === "deaktiviert" ? 'deaktiviert ' : `${esc(f.status)} `}
Bearbeiten Löschen
`).join("") || `Keine Weiterleitungen. Beispiel: WireGuard → UDP, Port 40000 → vpn.firma.de:51820 `}
`;
$("#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) => `${name === "username" ? "Alle Benutzer" : name === "status" ? "Alle Status" : "Alle Protokolle"} ${opts.map(([v, l]) => `${esc(l)} `).join("")} `;
main.innerHTML = head(admin ? "Verlauf" : "Mein Verlauf", "Wer hat wann was aufgerufen",
admin ? `CSV-Export Verlauf leeren ` : "") +
`
Zeit ${admin ? "Benutzer " : ""}Client Protokoll Ziel Status ↑ ↓ Dauer
${data.items.map((r) => `
${fmtTime(r.ts)}
${admin ? `${r.username ? `${esc(r.username)} ` : '– '} ` : ""}
${esc(r.client_ip)}
${protoBadge(r.proto)}
${esc(r.method && r.proto === "http" ? r.method + " " : "")}${esc(r.url || (r.host ? `${r.host}:${r.port}` : ""))} ${r.detail ? `${esc(r.detail)}
` : ""}
${statusBadge(r.status)}
${fmtBytes(r.bytes_up)} ${fmtBytes(r.bytes_down)} ${fmtDur(r.duration)}
`).join("") || `Keine Einträge `}
`;
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") + `
`;
const draw = async () => {
const list = await api("/api/connections");
const el = $("#conns");
if (!el) return;
el.innerHTML = `Seit Benutzer Client Protokoll Ziel ↑ ↓
${list.map((c) => `${fmtDur(Date.now() / 1000 - c.started)} ${esc(c.username || "–")} ${esc(c.client_ip)}
${protoBadge(c.proto)} ${esc(c.url || `${c.host}:${c.port}`)}
${fmtBytes(c.bytes_up)} ${fmtBytes(c.bytes_down)}
Trennen `).join("") || `Keine aktiven Verbindungen `}
`;
};
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 `Clients einrichten
HTTP/HTTPS-Proxy ${esc(host)}:${s.ports.http}
SOCKS5-Proxy ${esc(host)}:${s.ports.socks}
Auto-Konfiguration (PAC) http://${esc(host)}:${s.ports.web}/proxy.pac
SOCKS5 UDP-Ports ${esc(s.ports.socks_udp)}
Weiterleitungs-Ports ${esc(s.ports.forwards)}
Beide Proxy-Ports verstehen HTTP und SOCKS5. Browser: HTTP-Proxy oder PAC-URL eintragen.
Thunderbird & Co. (SMTP, IMAP, POP3): SOCKS5 eintragen. Outlook/Exchange: läuft über HTTPS (Systemproxy);
für SMTP/IMAP in Outlook oder WireGuard eine Weiterleitung anlegen.
Kommandozeile: export https_proxy=http://user:pass@${esc(host)}:${s.ports.http}
`;
}
VIEWS.settings = async (main) => {
const s = await api("/api/settings");
main.innerHTML = head("Einstellungen") + ``;
$("#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") + `
HTTP/HTTPS-Proxy ${esc(host)}:3128
SOCKS5 ${esc(host)}:1080
PAC-Datei http://${esc(host)}:${esc(location.port || 80)}/proxy.pac
Anmeldung Dein Benutzername und Passwort `;
};
// ---- 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. Interaktive API-Doku öffnen ↗ `,
`+ API-Token `) + `
Name Besitzer Erstellt Läuft ab Zuletzt benutzt
${tokens.map((t) => `${esc(t.name)} ${esc(t.username)} ${fmtTime(t.created_at)} ${t.expires_at ? fmtTime(t.expires_at) : "nie"} ${ago(t.last_used)}
Widerrufen `).join("") || `Noch keine API-Tokens `}
Beispiele # Benutzer anlegen
curl -X POST ${esc(location.origin)}/api/users \\
-H "Authorization: Bearer <TOKEN>" -H "Content-Type: application/json" \\
-d '{"username":"max","password":"geheim123"}'
# Domain global sperren
curl -X POST ${esc(location.origin)}/api/rules \\
-H "Authorization: Bearer <TOKEN>" -H "Content-Type: application/json" \\
-d '{"action":"deny","host":"facebook.com"}'
# Verlauf eines Benutzers
curl "${esc(location.origin)}/api/history?username=max&limit=50" -H "Authorization: Bearer <TOKEN>" `;
$("#add").onclick = async () => {
const out = await modalLoop("API-Token erstellen", `Name
Gültigkeit (Tage) `,
(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", `Bitte jetzt kopieren – er wird nicht noch einmal angezeigt:
${esc(out.token)}
Verwendung: Header Authorization: Bearer <Token> oder X-API-Key: <Token>
`, { 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 ${esc(state.me.username)} `;
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", `Aktuelles Passwort
Neues Passwort `,
(d) => api("/api/me/password", { method: "POST", body: d }));
if (ok) toast("Passwort geändert");
};
window.addEventListener("hashchange", () => state.me && route());
start();