first commit
This commit is contained in:
@@ -0,0 +1,624 @@
|
||||
"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 `<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 *.tiktok.com 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 & 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 <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>"</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 <Token></code> oder <code>X-API-Key: <Token></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();
|
||||
@@ -0,0 +1,42 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Proxy Manager</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 32 32%22%3E%3Crect width=%2232%22 height=%2232%22 rx=%227%22 fill=%22%232a78d6%22/%3E%3Ctext x=%2216%22 y=%2223%22 font-size=%2219%22 text-anchor=%22middle%22 fill=%22white%22%3E%E2%87%84%3C/text%3E%3C/svg%3E">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Login -->
|
||||
<section id="login" class="login" hidden>
|
||||
<form id="login-form" class="login-card">
|
||||
<div class="brand"><span class="logo">⇄</span> Proxy Manager</div>
|
||||
<label>Benutzername<input name="username" autocomplete="username" required autofocus></label>
|
||||
<label>Passwort<input name="password" type="password" autocomplete="current-password" required></label>
|
||||
<button class="btn primary" type="submit">Anmelden</button>
|
||||
<p class="error" id="login-error"></p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- App -->
|
||||
<div id="app" class="app" hidden>
|
||||
<aside class="sidebar">
|
||||
<div class="brand"><span class="logo">⇄</span> Proxy Manager</div>
|
||||
<nav id="nav"></nav>
|
||||
<div class="sidebar-foot">
|
||||
<div class="whoami" id="whoami"></div>
|
||||
<button class="btn ghost small" id="btn-password">Passwort ändern</button>
|
||||
<button class="btn ghost small" id="btn-logout">Abmelden</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main id="main"></main>
|
||||
</div>
|
||||
|
||||
<dialog id="modal"><form method="dialog" id="modal-form"></form></dialog>
|
||||
<div id="toast" class="toast" hidden></div>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,160 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f4f5f7;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f8f9fb;
|
||||
--border: #e3e6eb;
|
||||
--text: #1d2330;
|
||||
--text-2: #52596a;
|
||||
--muted: #8a91a0;
|
||||
--accent: #2a78d6;
|
||||
--accent-ink: #ffffff;
|
||||
--series-1: #2a78d6;
|
||||
--good: #1a7f37;
|
||||
--good-bg: #e6f4ea;
|
||||
--bad: #c62828;
|
||||
--bad-bg: #fdecea;
|
||||
--warn: #9a6700;
|
||||
--warn-bg: #fff4d6;
|
||||
--sidebar: #1b2130;
|
||||
--sidebar-text: #c9cfdb;
|
||||
--radius: 10px;
|
||||
--shadow: 0 1px 2px rgba(16, 24, 40, .05), 0 1px 3px rgba(16, 24, 40, .06);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #12151c;
|
||||
--surface: #1a1e27;
|
||||
--surface-2: #20252f;
|
||||
--border: #2c3240;
|
||||
--text: #e8ebf1;
|
||||
--text-2: #b4bccb;
|
||||
--muted: #7d8697;
|
||||
--accent: #3987e5;
|
||||
--series-1: #3987e5;
|
||||
--good: #4ac26b;
|
||||
--good-bg: #16301f;
|
||||
--bad: #f07070;
|
||||
--bad-bg: #3a1c1c;
|
||||
--warn: #e0b341;
|
||||
--warn-bg: #352b10;
|
||||
--sidebar: #0d1016;
|
||||
--shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font: 14px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; background: var(--bg); color: var(--text); }
|
||||
[hidden] { display: none !important; }
|
||||
h1 { font-size: 22px; margin: 0; }
|
||||
h2 { font-size: 15px; margin: 0 0 12px; }
|
||||
a { color: var(--accent); }
|
||||
code, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12.5px; }
|
||||
|
||||
/* Login */
|
||||
.login { min-height: 100vh; display: grid; place-items: center; padding: 16px; }
|
||||
.login-card { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; padding: 32px; width: 100%; max-width: 360px; display: grid; gap: 14px; box-shadow: var(--shadow); }
|
||||
.login-card .brand { color: var(--text); margin-bottom: 8px; }
|
||||
.error { color: var(--bad); min-height: 1em; margin: 0; }
|
||||
|
||||
/* Layout */
|
||||
.app { display: grid; grid-template-columns: 230px minmax(0, 1fr); min-height: 100vh; }
|
||||
.sidebar { background: var(--sidebar); color: var(--sidebar-text); display: flex; flex-direction: column; padding: 18px 12px; position: sticky; top: 0; height: 100vh; }
|
||||
.brand { display: flex; align-items: center; gap: 10px; font-weight: 650; font-size: 16px; color: #fff; padding: 0 8px 18px; }
|
||||
.logo { display: inline-grid; place-items: center; width: 30px; height: 30px; border-radius: 8px; background: var(--accent); color: #fff; font-size: 16px; }
|
||||
nav { display: grid; gap: 2px; }
|
||||
nav a { color: var(--sidebar-text); text-decoration: none; padding: 9px 12px; border-radius: 8px; display: flex; gap: 10px; align-items: center; }
|
||||
nav a:hover { background: rgba(255, 255, 255, .06); color: #fff; }
|
||||
nav a.active { background: rgba(255, 255, 255, .1); color: #fff; font-weight: 600; }
|
||||
nav .ico { width: 18px; text-align: center; opacity: .85; }
|
||||
.sidebar-foot { margin-top: auto; display: grid; gap: 6px; padding: 12px 4px 0; border-top: 1px solid rgba(255, 255, 255, .08); }
|
||||
.whoami { font-size: 13px; padding: 0 8px 4px; }
|
||||
.sidebar .btn.ghost { color: var(--sidebar-text); border-color: rgba(255, 255, 255, .12); }
|
||||
main { padding: 24px 28px 48px; min-width: 0; }
|
||||
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 18px; }
|
||||
.page-head p { margin: 4px 0 0; color: var(--text-2); }
|
||||
|
||||
/* Cards & grid */
|
||||
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px; box-shadow: var(--shadow); min-width: 0; }
|
||||
.grid { display: grid; gap: 16px; }
|
||||
.grid.tiles { grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); }
|
||||
.grid.two { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); }
|
||||
.tile .label { color: var(--text-2); font-size: 12.5px; }
|
||||
.tile .value { font-size: 26px; font-weight: 650; margin-top: 4px; font-variant-numeric: tabular-nums; }
|
||||
.tile .sub { color: var(--muted); font-size: 12px; margin-top: 2px; }
|
||||
.stack { display: grid; gap: 16px; }
|
||||
|
||||
/* Buttons & forms */
|
||||
a.btn { text-decoration: none; display: inline-block; }
|
||||
.btn { border: 1px solid var(--border); background: var(--surface); color: var(--text); padding: 8px 14px; border-radius: 8px; cursor: pointer; font: inherit; font-weight: 550; white-space: nowrap; }
|
||||
.btn:hover { background: var(--surface-2); }
|
||||
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
|
||||
.btn.primary:hover { filter: brightness(1.07); }
|
||||
.btn.danger { color: var(--bad); }
|
||||
.btn.ghost { background: transparent; }
|
||||
.btn.small { padding: 5px 10px; font-size: 12.5px; }
|
||||
.btn:disabled { opacity: .5; cursor: default; }
|
||||
label { display: grid; gap: 5px; font-size: 13px; color: var(--text-2); font-weight: 550; }
|
||||
input, select, textarea { font: inherit; color: var(--text); background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; width: 100%; }
|
||||
textarea { min-height: 120px; resize: vertical; font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12.5px; }
|
||||
input:focus, select:focus, textarea:focus { outline: 2px solid color-mix(in srgb, var(--accent) 40%, transparent); border-color: var(--accent); }
|
||||
.check { display: flex; align-items: center; gap: 8px; font-weight: 500; color: var(--text); }
|
||||
.check input { width: auto; }
|
||||
.hint { color: var(--muted); font-size: 12px; font-weight: 400; }
|
||||
.toolbar { display: flex; gap: 8px; flex-wrap: wrap; align-items: end; margin-bottom: 14px; }
|
||||
.toolbar > * { flex: 0 1 auto; }
|
||||
.toolbar input, .toolbar select { width: auto; min-width: 140px; }
|
||||
.form-grid { display: grid; gap: 14px; align-items: start; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
|
||||
|
||||
/* Tables */
|
||||
.table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; padding: 9px 12px; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
||||
th { font-size: 12px; color: var(--text-2); font-weight: 600; background: var(--surface-2); white-space: nowrap; }
|
||||
tr:last-child td { border-bottom: 0; }
|
||||
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
td.actions { text-align: right; white-space: nowrap; }
|
||||
td.wrap { max-width: 420px; overflow-wrap: anywhere; }
|
||||
.empty { padding: 28px; text-align: center; color: var(--muted); }
|
||||
.card .table-wrap { border: 0; }
|
||||
.mini td, .mini th { padding: 6px 4px; }
|
||||
|
||||
/* Badges */
|
||||
.badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 999px; font-size: 12px; font-weight: 600; background: var(--surface-2); color: var(--text-2); border: 1px solid var(--border); white-space: nowrap; }
|
||||
.badge.ok { background: var(--good-bg); color: var(--good); border-color: transparent; }
|
||||
.badge.bad { background: var(--bad-bg); color: var(--bad); border-color: transparent; }
|
||||
.badge.warn { background: var(--warn-bg); color: var(--warn); border-color: transparent; }
|
||||
.badge.info { background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent); border-color: transparent; }
|
||||
|
||||
/* Bar chart */
|
||||
.chart { position: relative; }
|
||||
.chart svg { display: block; width: 100%; height: 180px; overflow: visible; }
|
||||
.chart .bar { fill: var(--series-1); }
|
||||
.chart .bar.hover { fill: color-mix(in srgb, var(--series-1) 75%, var(--text)); }
|
||||
.chart .axis { fill: var(--muted); font-size: 11px; }
|
||||
.chart .grid-line { stroke: var(--border); stroke-width: 1; }
|
||||
.chart-tip { position: absolute; pointer-events: none; background: var(--text); color: var(--bg); padding: 6px 9px; border-radius: 6px; font-size: 12px; white-space: nowrap; transform: translate(-50%, -110%); }
|
||||
|
||||
/* Modal & toast */
|
||||
dialog { border: 1px solid var(--border); border-radius: 14px; padding: 0; background: var(--surface); color: var(--text); width: min(560px, calc(100vw - 32px)); box-shadow: 0 20px 60px rgba(0, 0, 0, .25); }
|
||||
dialog::backdrop { background: rgba(10, 14, 22, .45); }
|
||||
#modal-form { padding: 22px; display: grid; gap: 14px; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
|
||||
.toast { position: fixed; bottom: 20px; right: 20px; background: var(--text); color: var(--bg); padding: 10px 16px; border-radius: 8px; box-shadow: 0 8px 24px rgba(0, 0, 0, .2); z-index: 100; max-width: calc(100vw - 40px); }
|
||||
.toast.err { background: var(--bad); color: #fff; }
|
||||
.secret { padding: 10px; background: var(--surface-2); border: 1px dashed var(--border); border-radius: 8px; overflow-wrap: anywhere; user-select: all; }
|
||||
.pager { display: flex; gap: 8px; align-items: center; justify-content: flex-end; margin-top: 12px; color: var(--text-2); }
|
||||
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; }
|
||||
.kv dt { color: var(--text-2); }
|
||||
.kv dd { margin: 0; }
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.app { grid-template-columns: minmax(0, 1fr); }
|
||||
.sidebar { position: static; height: auto; padding: 12px; }
|
||||
nav { grid-auto-flow: column; overflow-x: auto; }
|
||||
nav a { white-space: nowrap; }
|
||||
.sidebar-foot { display: flex; flex-wrap: wrap; align-items: center; margin-top: 12px; }
|
||||
.toolbar input, .toolbar select { min-width: 0; flex: 1 1 140px; }
|
||||
main { padding: 16px; }
|
||||
}
|
||||
Reference in New Issue
Block a user