ctf-ad-mirror-tool-rust/web/app.js

252 lines
21 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";
(() => {
const $ = (id) => document.getElementById(id);
const state = { snapshot: null, csrf: null, source: null, busy: false, stale: true, cards: new Map(), editId: null, watchdog: null };
const statusClass = { 101: "up", 102: "corrupt", 103: "mumble", 104: "down", 110: "fail" };
const statusOrder = { 101: 0, 102: 1, 103: 2, 104: 3, 110: 4 };
const statusName = { 101: "UP", 102: "CORRUPT", 103: "MUMBLE", 104: "DOWN", 110: "CHECK_FAILED" };
const el = (tag, cls = "", text = "") => { const node = document.createElement(tag); node.className = cls; node.textContent = text; return node; };
const serviceById = (id) => state.snapshot?.services.find((service) => service.id === id);
const teamById = (id) => state.snapshot?.teams.find((team) => team.id === id);
const portOf = (service) => Number(service.listen.slice(service.listen.lastIndexOf(":") + 1));
function button(cls, title, icon, action) {
const node = el("button", cls); node.type = "button"; node.title = title; node.setAttribute("aria-label", title);
if (icon) { const i = el("i", `fas fa-${icon}`); i.setAttribute("aria-hidden", "true"); node.append(i); }
node.addEventListener("click", (event) => { event.stopPropagation(); action(event); }); return node;
}
function alert(message) { $("alert-message").textContent = message; $("alert").hidden = !message; }
function closeStream() { state.source?.close(); state.source = null; clearTimeout(state.watchdog); }
function watchUpdates() {
clearTimeout(state.watchdog);
state.watchdog = setTimeout(() => { if (state.csrf) { state.stale = true; render(); alert("Нет свежих обновлений от сервера. Обновите подключение."); } }, 5000);
}
function showLogin() {
closeStream(); state.csrf = null; state.snapshot = null; state.stale = true;
for (const dialog of document.querySelectorAll("dialog[open]")) dialog.close();
$("header").hidden = true; $("dashboard").hidden = true; $("login").hidden = false; document.title = "Вход";
}
async function api(path, options = {}) {
const headers = { ...options.headers };
if (options.body !== undefined) headers["Content-Type"] = "application/json";
if (state.csrf && options.method && options.method !== "GET") headers["X-CSRF-Token"] = state.csrf;
let response;
try { response = await fetch(path, { ...options, headers, credentials: "same-origin" }); }
catch { throw new Error("Нет связи с сервером. Проверьте подключение и обновите страницу."); }
const data = await response.json().catch(() => ({}));
if (!response.ok) {
if (response.status === 401 && path !== "/api/login") showLogin();
const error = new Error(data.error || `Ошибка сервера (${response.status})`); error.status = response.status; throw error;
}
return data;
}
function config(service) {
return Object.fromEntries(["id", "name", "listen", "upstream_port", "primary", "replicas", "enabled", "auto_switch", "replica_policy", "custom", "custom_ip"].map((key) => [key, service[key] ?? ({ custom: false, custom_ip: null }[key])]));
}
async function refresh() { render(await api("/api/snapshot")); watchUpdates(); }
async function mutate(path, method, body, errorId) {
if (state.busy || state.stale) return false;
state.busy = true; render(); if (errorId) $(errorId).textContent = "";
try {
await api(path, { method, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
await refresh(); alert(""); return true;
} catch (error) {
if (errorId) $(errorId).textContent = error.message; else alert(error.message);
try { if (state.csrf) await refresh(); } catch { /* Preserve the actual mutation error. */ }
return false;
} finally { state.busy = false; render(); }
}
function updateService(id, patch, errorId) {
const current = serviceById(id); if (!current) return Promise.resolve(false);
return mutate(`/api/services/${encodeURIComponent(id)}`, "PUT", { ...config(current), ...patch }, errorId);
}
function startStream() {
closeStream(); const source = new EventSource("/api/events"); state.source = source;
source.addEventListener("snapshot", (event) => {
if (state.source !== source) return;
try { const wasStale = state.stale; render(JSON.parse(event.data)); watchUpdates(); if (wasStale) alert(""); }
catch { state.stale = true; render(); alert("Получены некорректные данные. Обновите страницу."); }
});
source.onerror = async () => {
if (state.source !== source) return;
state.stale = true; render(); alert("Нет обновлений от сервера. Переподключение…");
try { await api("/api/session"); } catch (error) { if (error.status !== 401) return; }
};
watchUpdates();
}
async function enter(session) {
state.csrf = session.csrf; $("password").value = ""; $("login-error").textContent = "";
await refresh(); $("login").hidden = true; $("header").hidden = false; $("dashboard").hidden = false;
document.title = "Proxy Manager"; startStream();
}
function openService(id) {
const service = serviceById(id); if (!service) return;
const hostname = window.location.hostname.replace(/^\[|\]$/g, "");
const host = hostname.includes(":") ? `[${hostname}]` : hostname;
window.open(`http://${host}:${portOf(service)}`, "_blank", "noopener,noreferrer");
}
function selectPrimary(id, teamId) {
const service = serviceById(id); if (!service || service.primary === teamId) return;
const replicas = service.replicas.filter((value) => value !== teamId);
if (service.primary && !replicas.includes(service.primary)) replicas.push(service.primary);
updateService(id, { primary: teamId, replicas, custom_ip: null });
}
function toggleReplica(id, teamId) {
const service = serviceById(id); if (!service || service.primary === teamId) return;
const replicas = service.replicas.includes(teamId) ? service.replicas.filter((value) => value !== teamId) : [...service.replicas, teamId];
updateService(id, { replicas });
}
function openSettings(id) {
const service = serviceById(id); if (!service || state.busy || state.stale) return;
state.editId = id; $("settings-error").textContent = "";
$("service-name").value = service.name; $("service-listen").value = service.listen;
$("service-port").value = service.upstream_port; $("service-policy").value = service.replica_policy;
$("settingsModal").showModal();
}
async function removeCustom(id) {
const service = serviceById(id); if (!service?.custom || state.busy || state.stale) return;
if (!window.confirm(`Удалить прокси «${service.name}»?`)) return;
if (service.enabled && !await updateService(id, { enabled: false })) return;
await mutate(`/api/services/${encodeURIComponent(id)}`, "DELETE");
}
function createCard(service) {
const id = service.id; const root = el("div", "task-column"); root.dataset.serviceId = id;
const strip = el("div", "strip sb-off");
const header = el("div", "task-header"); header.addEventListener("click", () => openService(id));
const wrap = el("div", "task-title-wrap"); const titleRow = el("div", "task-title-row");
const title = button("task-title task-title-button", "Настройки сервиса", null, () => openSettings(id));
const port = el("div", "task-port"); titleRow.append(title, port);
const selected = el("div", "selected-team", "Команда не выбрана"); wrap.append(titleRow, selected);
const controls = el("div", "task-controls");
const auto = button("auto-switch-button", "Авто-переключение", "robot", () => { const s = serviceById(id); if (s) updateService(id, { auto_switch: !s.auto_switch }); });
const open = button("open-button", "Открыть сервис", "up-right-from-square", () => openService(id));
const toggle = button("toggle-button", "Запустить/Остановить", null, () => {
const s = serviceById(id); if (!s) return;
if (!s.enabled && !s.primary) { alert("Выберите команду или введите IP"); return; }
updateService(id, { enabled: !s.enabled });
});
controls.append(auto, open, toggle); header.append(wrap, controls);
const customRow = el("div", "custom-ip-row"); const customIp = el("input", "input-modern");
customIp.placeholder = "IP"; customIp.setAttribute("aria-label", `IP: ${service.name}`); customIp.autocomplete = "off";
customIp.addEventListener("change", () => updateService(id, { custom_ip: customIp.value.trim() || null, replicas: [] }));
const remove = button("btn-delete", "Удалить", "trash", () => removeCustom(id)); customRow.append(customIp, remove);
const stats = el("div", "task-stats"); const values = {};
for (const [key, label, cls] of [["accepted", "Всего:", ""], ["active", "Активные:", "stat-active"], ["completed", "Закрыто:", ""], ["errors", "Ошибки:", "stat-error"]]) {
const item = el("div", "stat-item"); const value = el("span", `stat-value ${cls}`, "0"); values[key] = value;
item.append(el("span", "stat-label", label), value); stats.append(item);
}
const error = el("p", "service-error"); error.setAttribute("role", "status");
const panel = el("div", "command-panel"); const list = el("div", "command-list"); panel.append(el("div", "panel-title", "Команды"), list);
root.append(strip, header, customRow, stats, error, panel); $("service-board").append(root);
return { root, strip, title, port, selected, auto, toggle, customRow, customIp, remove, stats, values, error, list, rows: new Map() };
}
function createRow(card, id, team) {
const root = el("div", "command-item"); root.dataset.teamId = team.id; root.dataset.value = team.address;
root.setAttribute("role", "button"); root.tabIndex = 0;
root.addEventListener("click", () => toggleReplica(id, team.id));
root.addEventListener("keydown", (event) => { if (event.target === root && ["Enter", " "].includes(event.key)) { event.preventDefault(); toggleReplica(id, team.id); } });
const left = el("div", "command-left");
const primary = button("primary-area", `Основной маршрут ${team.name}`, null, () => selectPrimary(id, team.id));
const circle = el("span", "primary-toggle"); circle.append(el("span", "primary-dot")); primary.append(circle); primary.setAttribute("aria-pressed", "false");
const health = el("span", "option-status unknown"); left.append(primary, health);
const meta = el("div", "command-meta"); const name = el("span", "command-name"); const address = el("span", "command-ip"); const flags = el("span", "flag-chip", "-"); meta.append(name, address, flags);
root.append(left, meta); card.list.append(root); return { root, primary, health, name, address, flags };
}
function render(snapshot) {
if (snapshot) {
if (!Array.isArray(snapshot.services) || !Array.isArray(snapshot.teams) || !Number.isFinite(snapshot.now_ms)) throw new Error("Некорректный снимок");
if (state.snapshot && snapshot.now_ms < state.snapshot.now_ms) return;
state.snapshot = snapshot; state.stale = false;
}
if (!state.snapshot) return;
const current = state.snapshot; const board = current.scoreboard || {}; const scoreFresh = !state.stale && board.connected && !board.stale;
const locked = state.busy || state.stale;
$("add-service").disabled = locked; $("logout").disabled = state.busy;
for (const form of [$("customProxyForm"), $("settings-form")]) for (const node of form.querySelectorAll("input, select, button")) node.disabled = locked;
for (const node of document.querySelectorAll("[data-close]")) node.disabled = state.busy;
const ids = new Set(current.services.map((service) => service.id));
for (const [id, card] of state.cards) if (!ids.has(id)) { card.root.remove(); state.cards.delete(id); }
for (const service of current.services) {
let card = state.cards.get(service.id);
if (!card) { card = createCard(service); state.cards.set(service.id, card); }
card.title.textContent = service.name; card.port.textContent = `:${portOf(service)}`;
card.title.setAttribute("aria-label", `Настройки сервиса ${service.name}`); card.title.disabled = locked;
card.auto.classList.toggle("active", service.auto_switch); card.auto.setAttribute("aria-pressed", String(service.auto_switch));
card.auto.title = service.auto_switch ? "Авто-переключение включено" : "Авто-переключение"; card.auto.disabled = locked;
card.toggle.classList.toggle("active", Boolean(service.engine?.running)); card.toggle.disabled = locked;
card.toggle.setAttribute("aria-label", `${service.enabled ? "Остановить" : "Запустить"} ${service.name}`); card.toggle.setAttribute("aria-pressed", String(Boolean(service.engine?.running)));
card.customRow.hidden = !service.custom; card.customIp.disabled = locked; card.remove.disabled = locked;
if (document.activeElement !== card.customIp) card.customIp.value = service.custom_ip || "";
const primary = teamById(service.primary);
card.selected.replaceChildren();
if (primary) {
card.selected.append(el("span", "selected-team-name", primary.name));
if (primary.name !== primary.address) card.selected.append(el("span", "selected-team-ip", primary.address));
if (service.replicas.length) card.selected.append(el("span", "selected-team-tail", ` · дубли: ${service.replicas.length}`));
} else card.selected.textContent = "Команда не выбрана";
const stats = service.engine?.stats || {};
card.stats.style.visibility = service.engine?.running || stats.active ? "visible" : "hidden";
for (const key of ["accepted", "active", "completed"]) card.values[key].textContent = stats[key] || 0;
const errors = (stats.connect_errors || 0) + (stats.io_errors || 0) + (stats.rejected || 0) + (stats.accept_errors || 0);
card.values.errors.textContent = errors; card.values.errors.classList.toggle("text-danger", errors > 0);
card.stats.title = `Байты: ↑ ${stats.bytes_up || 0}, ↓ ${stats.bytes_down || 0}; реплики: ${stats.replica_bytes || 0}; отключено реплик: ${stats.replica_disconnects || 0}; ошибок реплик: ${stats.replica_errors || 0}`;
card.error.textContent = service.engine?.last_error ? `Последняя ошибка: ${service.engine.last_error}` : "";
const localTeam = current.teams.find((team) => team.address === current.main_team_ip);
const boardTeam = board.teams?.find((team) => team.address === current.main_team_ip || (localTeam && team.name === localTeam.name));
const boardTask = board.tasks?.find((task) => task.name === service.name || task.name === service.id);
const entry = scoreFresh && current.main_team_ip ? board.entries?.[boardTask?.id]?.[boardTeam?.id] : null;
card.strip.className = `strip sb-${statusClass[entry?.status] || "off"}`;
card.strip.title = !board.enabled ? "Scoreboard выключен" : !current.main_team_ip ? "Для статуса своей команды задайте main_team_ip в конфигурации" : scoreFresh ? `Своя команда: ${statusName[entry?.status] || "нет данных"}` : "Данные scoreboard устарели или соединение отсутствует";
const teams = current.teams.filter((team) => !team.hidden || service.primary === team.id || service.replicas.includes(team.id));
const teamIds = new Set(teams.map((team) => team.id));
for (const [id, row] of card.rows) if (!teamIds.has(id)) { row.root.remove(); card.rows.delete(id); }
for (const team of teams) {
let row = card.rows.get(team.id); if (!row) { row = createRow(card, service.id, team); card.rows.set(team.id, row); }
row.name.textContent = team.name; row.address.textContent = team.address; row.address.hidden = team.name === team.address;
row.root.classList.toggle("primary-active", service.primary === team.id); row.root.classList.toggle("replica-active", service.replicas.includes(team.id));
row.root.setAttribute("aria-label", `Реплика ${team.name}: ${service.name}`); row.root.setAttribute("aria-disabled", String(locked || service.primary === team.id));
row.root.setAttribute("aria-pressed", String(service.replicas.includes(team.id))); row.primary.disabled = locked;
row.primary.setAttribute("aria-pressed", String(service.primary === team.id));
const h = service.health?.[team.id]; const recent = !state.stale && h?.checked_at_ms && current.now_ms - h.checked_at_ms <= (current.health_max_age_ms || 12000);
row.health.className = `option-status ${recent ? h.reachable ? "available" : "unavailable" : "unknown"}`;
row.health.title = recent ? `TCP: ${h.reachable ? "доступен" : "недоступен"}, ${h.latency_ms} мс` : "TCP: нет свежих данных";
const score = service.scoreboard?.[team.id]; const cls = scoreFresh ? statusClass[score?.status] || "off" : "off";
row.name.className = `command-name sb-${cls}`; row.flags.classList.toggle("stale-data", !scoreFresh);
row.flags.textContent = score ? `-${score.lost ?? 0}` : "-";
row.flags.title = score ? `${statusName[score.status] || score.status}; потеряно: ${score.lost || 0}; украдено: ${score.stolen || 0}${scoreFresh ? "" : "; устаревшие данные"}` : "Нет данных scoreboard";
}
if (scoreFresh && !card.list.contains(document.activeElement)) {
const ordered = [...teams].sort((a, b) => {
const sa = service.scoreboard?.[a.id]; const sb = service.scoreboard?.[b.id];
return (statusOrder[sa?.status] ?? 5) - (statusOrder[sb?.status] ?? 5) || (sa?.lost ?? Number.MAX_SAFE_INTEGER) - (sb?.lost ?? Number.MAX_SAFE_INTEGER) || a.name.localeCompare(b.name);
});
ordered.forEach((team, index) => { const node = card.rows.get(team.id).root; if (card.list.children[index] !== node) card.list.insertBefore(node, card.list.children[index] || null); });
}
}
}
$("dismiss-alert").addEventListener("click", () => alert(""));
$("login-form").addEventListener("submit", async (event) => {
event.preventDefault(); $("login-button").disabled = true; $("login-error").textContent = "";
try { await enter(await api("/api/login", { method: "POST", body: JSON.stringify({ password: $("password").value }) })); }
catch (error) { $("login-error").textContent = error.message; }
finally { $("login-button").disabled = false; }
});
$("logout").addEventListener("click", async () => { if (state.busy) return; try { await api("/api/logout", { method: "POST" }); showLogin(); alert(""); } catch (error) { alert(error.message); } });
$("refresh").addEventListener("click", async () => { try { await refresh(); alert(""); if (!state.source) startStream(); } catch (error) { alert(error.message); } });
$("add-service").addEventListener("click", () => { $("customProxyForm").reset(); $("custom-error").textContent = ""; $("addCustomModal").showModal(); });
for (const node of document.querySelectorAll("[data-close]")) node.addEventListener("click", () => { if (!state.busy) $(node.dataset.close).close(); });
for (const dialog of document.querySelectorAll("dialog")) dialog.addEventListener("cancel", (event) => { if (state.busy) event.preventDefault(); });
$("customProxyForm").addEventListener("submit", async (event) => {
event.preventDefault(); const port = Number($("customPort").value);
const id = `custom-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
const body = { id, name: $("customName").value.trim(), listen: `0.0.0.0:${port}`, upstream_port: port, primary: null, replicas: [], enabled: false, auto_switch: false, replica_policy: "isolate", custom: true, custom_ip: $("customIp").value.trim() || null };
if (await mutate(`/api/services/${encodeURIComponent(id)}`, "PUT", body, "custom-error")) $("addCustomModal").close();
});
$("settings-form").addEventListener("submit", async (event) => {
event.preventDefault();
if (await updateService(state.editId, { name: $("service-name").value.trim(), listen: $("service-listen").value.trim(), upstream_port: Number($("service-port").value), replica_policy: $("service-policy").value }, "settings-error")) $("settingsModal").close();
});
window.addEventListener("pagehide", closeStream);
window.addEventListener("pageshow", (event) => { if (event.persisted && state.csrf) startStream(); });
(async () => { try { await enter(await api("/api/session")); } catch (error) { showLogin(); if (error.status !== 401) $("login-error").textContent = error.message; } })();
})();