diff --git a/README.md b/README.md index 3e54dca..d83ca2f 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ cd /opt/amnezia-admin && chmod +x scripts/install.sh && sudo SKIP_DOWNLOAD=1 bas | `DATA_DIR` | `/opt/amnezia-admin-data` | Том с `password.hash` и сессией | | `HOST_PORT` | `8080` | Порт HTTP панели на хосте | | `AWG_CONTAINER` | `amnezia-awg2` | Имя контейнера Amnezia WG | +| `AWG_PROFILES` | _(нет)_ | JSON-массив профилей: несколько контейнеров/путей (см. ниже). Если задан — переключатель «Инстанс» в вебе | +| `SCHEDULE_DISCONNECT_MS` | `60000` | Как часто планировщик проверяет отложенное отключение из туннеля (мс) | | `ADMIN_PASSWORD` | _(генерируется)_ | Первый пароль вместо файла | | `SKIP_DOWNLOAD` | `0` | `1` — не качать GitHub, собрать из `INSTALL_DIR` | | `ALLOW_DEFAULT_PASSWORD` | `0` | `1` — см. раздел «Пароль» ниже | @@ -44,6 +46,17 @@ cd /opt/amnezia-admin && chmod +x scripts/install.sh && sudo SKIP_DOWNLOAD=1 bas | `LANDING_CONTAINER` | `amnezia-web-landing` | Имя контейнера лендинга | | `NO_CACHE` | `0` | `1` — `docker build --no-cache` при проблемах с обновлением образа | +#### Несколько инстансов (AmneziaWG + Legacy и т.д.) + +Пути и имена контейнеров на сервере могут отличаться — проверьте внутри контейнера (`docker exec … ls /opt/amnezia`). Пример **двух** профилей при запуске установщика (одна строка JSON в кавычках): + +```bash +AWG_PROFILES='[{"id":"awg","label":"AmneziaWG","container":"amnezia-awg2","confPath":"/opt/amnezia/awg/awg0.conf","clientsPath":"/opt/amnezia/awg/clientsTable","iface":"awg0","wgBinary":"awg"},{"id":"legacy","label":"AmneziaWG Legacy","container":"amnezia-wg0","confPath":"/opt/amnezia/wireguard/wg0.conf","clientsPath":"/opt/amnezia/wireguard/clientsTable","iface":"wg0","wgBinary":"wg"}]' \ +curl -fsSL https://raw.githubusercontent.com/andrey271192/Amnezia_web/main/scripts/install.sh | sudo -E bash +``` + +Для **Legacy** часто используется обычный `wg`, для новой AmneziaWG — `awg`; подставьте свои `container`, `confPath`, `clientsPath`, `iface`, `pskPath` при необходимости. + После установки: **админ-панель** `http://IP:8080` (или ваш `HOST_PORT`), **страница с поддержкой проекта** `http://IP/` на порту лендинга (по умолчанию **80**). Кнопка на лендинге ведёт на админку с тем же `HOST_PORT`. Футер с ссылками (**Amnezia Admin WebUI**, Boosty, Ozon СБП, Telegram) в админке находится **внизу страницы** — прокрутите ниже таблицы. diff --git a/public/app.js b/public/app.js index 560e42e..340c030 100644 --- a/public/app.js +++ b/public/app.js @@ -14,6 +14,10 @@ const statusEl = document.querySelector("#status"); const peerCountEl = document.querySelector("#peer-count"); const wgShowEl = document.querySelector("#wg-show"); +const protoSwitch = document.querySelector("#proto-switch"); +const protoSelect = document.querySelector("#proto-select"); +const protoLabel = document.querySelector("#proto-label"); + const pwForm = document.querySelector("#pw-form"); const pwCurrent = document.querySelector("#pw-current"); const pwNew = document.querySelector("#pw-new"); @@ -28,7 +32,7 @@ const dtCancel = document.querySelector("#dt-dialog-cancel"); const dtOk = document.querySelector("#dt-dialog-ok"); const dtExtra = document.querySelector("#dt-dialog-extra"); const dtHint = document.querySelector("#dt-dialog-hint"); -const dtAlsoDisable = document.querySelector("#dt-dialog-also-disable"); +const dtScheduleTunnel = document.querySelector("#dt-dialog-schedule-tunnel"); let dtMode = "disable"; /** @type {Record | null} */ @@ -61,7 +65,7 @@ function openDisableDialog(c) { dtOk.textContent = "Выключить"; dtInput.value = isoToDatetimeLocal(new Date().toISOString()); dtExtra.classList.add("hidden"); - dtAlsoDisable.checked = false; + dtScheduleTunnel.checked = false; dtDialog.showModal(); } @@ -72,16 +76,19 @@ function openEditDisconnectDialog(c) { dtClientEl.textContent = c.name; dtOk.textContent = "Сохранить"; const iso = - c.lastDisconnectedAt || (!c.activeInConf && c.disabledAt) || new Date().toISOString(); + (c.activeInConf && c.scheduledTunnelDisconnectAt) || + c.lastDisconnectedAt || + (!c.activeInConf && c.disabledAt) || + new Date().toISOString(); dtInput.value = isoToDatetimeLocal(iso); if (c.activeInConf) { dtExtra.classList.remove("hidden"); dtHint.textContent = - "Без галочки меняется только дата в таблице — в туннеле клиент остаётся. Чтобы реально отключить ключ, включите «Выключить из туннеля»."; - dtAlsoDisable.checked = false; + "Без галочки — только запись даты в таблице, клиент остаётся в туннеле. С галочкой ключ будет убран из туннеля автоматически в выбранный момент (проверка на сервере каждые ~60 с)."; + dtScheduleTunnel.checked = Boolean(c.scheduledTunnelDisconnectAt); } else { dtExtra.classList.add("hidden"); - dtAlsoDisable.checked = false; + dtScheduleTunnel.checked = false; } dtDialog.showModal(); } @@ -108,19 +115,16 @@ dtOk.addEventListener("click", async () => { body: JSON.stringify({ clientId: dtClient.clientId, disconnectedAt: iso }), }); } else { - const alsoTunnel = Boolean(dtAlsoDisable.checked && dtClient.activeInConf); - setStatus(alsoTunnel ? "Выключаю из туннеля…" : "Сохраняю дату…", false); - if (alsoTunnel) { - await api("/api/clients/disable", { - method: "POST", - body: JSON.stringify({ clientId: dtClient.clientId, disconnectedAt: iso }), - }); - } else { - await api("/api/clients/disconnect-date", { - method: "POST", - body: JSON.stringify({ clientId: dtClient.clientId, disconnectedAt: iso }), - }); - } + const scheduleTunnel = Boolean(dtScheduleTunnel.checked && dtClient.activeInConf); + setStatus(scheduleTunnel ? "Сохраняю расписание отключения…" : "Сохраняю дату…", false); + await api("/api/clients/disconnect-date", { + method: "POST", + body: JSON.stringify({ + clientId: dtClient.clientId, + disconnectedAt: iso, + scheduleTunnelDisconnect: scheduleTunnel, + }), + }); } dtDialog.close(); dtClient = null; @@ -187,6 +191,28 @@ async function checkSession() { } } +async function loadProtocols() { + try { + const data = await api("/api/protocols"); + protoLabel.textContent = `Протокол: ${data.currentLabel || "AmneziaWG"}`; + if (!data.profiles || data.profiles.length < 2) { + protoSwitch.classList.add("hidden"); + return; + } + protoSwitch.classList.remove("hidden"); + protoSelect.innerHTML = ""; + for (const p of data.profiles) { + const opt = document.createElement("option"); + opt.value = p.id; + opt.textContent = `${p.label} (${p.container})`; + if (p.id === data.currentId) opt.selected = true; + protoSelect.appendChild(opt); + } + } catch { + protoSwitch.classList.add("hidden"); + } +} + loginForm.addEventListener("submit", async (ev) => { ev.preventDefault(); loginError.textContent = ""; @@ -197,6 +223,7 @@ loginForm.addEventListener("submit", async (ev) => { }); loginPassword.value = ""; showApp(); + await loadProtocols(); await loadClients(); } catch (e) { loginError.textContent = String(e.message || e); @@ -217,6 +244,22 @@ refreshBtn.addEventListener("click", () => { loadClients(); }); +protoSelect.addEventListener("change", async () => { + try { + setStatus("Смена инстанса…", false); + await api("/api/protocol", { + method: "POST", + body: JSON.stringify({ profileId: protoSelect.value }), + }); + await loadProtocols(); + await loadClients(); + setStatus("", false); + } catch (e) { + setStatus(String(e.message || e), true); + await loadProtocols(); + } +}); + clockSyncBtn.addEventListener("click", () => { void refreshServerClock(); }); @@ -318,6 +361,12 @@ const dtRu = new Intl.DateTimeFormat("ru-RU", { }); function formatLastDisconnect(c) { + if (c.scheduledTunnelDisconnectAt && c.activeInConf) { + const d = new Date(String(c.scheduledTunnelDisconnectAt)); + if (!Number.isNaN(d.getTime())) { + return `${dtRu.format(d)} · авто`; + } + } const iso = c.lastDisconnectedAt || (!c.activeInConf && c.disabledAt) || @@ -470,7 +519,8 @@ async function loadClients() { try { setStatus("Загрузка…", false); const data = await api("/api/clients"); - peerCountEl.textContent = `${data.clients.length} в таблице · ${data.peerCount} peer в awg0.conf`; + const pref = data.profileLabel ? `${data.profileLabel} · ` : ""; + peerCountEl.textContent = `${pref}${data.clients.length} в таблице · ${data.peerCount} peer`; wgShowEl.textContent = data.wgShow || ""; renderRows(data.clients); setStatus("", false); @@ -494,6 +544,7 @@ async function boot() { const ok = await checkSession(); if (ok) { showApp(); + await loadProtocols(); await loadClients(); } else { showLogin(); diff --git a/public/index.html b/public/index.html index bde2156..900a703 100644 --- a/public/index.html +++ b/public/index.html @@ -29,7 +29,7 @@

Панель сервера

Пользователи AmneziaWG

-

Дату отключения вы задаёте сами: при «Выключить» или в диалоге «Задать дату» (для активных клиентов там можно отметить выключение из туннеля). Время — локальное на вашем компьютере.

+

Дату отключения задаёте сами (локальное время браузера). Кнопка «Выключить» убирает клиента из туннеля сразу. «Задать дату» без галочки — только запись в таблице; с галочкой — клиент остаётся в туннеле до наступления времени. Несколько инстансов AmneziaWG (Legacy и др.) — в переменной AWG_PROFILES и списке «Инстанс».

@@ -52,7 +52,11 @@
-
Протокол: AmneziaWG
+
Протокол: AmneziaWG
+
Сервер @@ -127,8 +131,8 @@
diff --git a/public/styles.css b/public/styles.css index 47c0691..2af47b2 100644 --- a/public/styles.css +++ b/public/styles.css @@ -201,10 +201,32 @@ h1 { margin-top: 1.25rem; display: flex; gap: 0.75rem; - align-items: center; + align-items: flex-start; flex-wrap: wrap; } +.proto-switch { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 12rem; +} + +.proto-switch-label { + font-size: 0.78rem; +} + +.proto-select { + padding: 0.45rem 0.55rem; + border-radius: 10px; + border: 1px solid var(--line); + background: #0a0f16; + color: var(--text); + font: inherit; + font-size: 0.85rem; + max-width: 22rem; +} + .clock-strip { display: flex; flex-direction: column; diff --git a/scripts/install.sh b/scripts/install.sh index ffe0fe0..012a482 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -97,6 +97,10 @@ RUN_ENV=( -e AWG_CONTAINER="${AWG_CONTAINER:-amnezia-awg2}" ) +if [[ -n "${AWG_PROFILES:-}" ]]; then + RUN_ENV+=( -e "AWG_PROFILES=${AWG_PROFILES}" ) +fi + if [[ -n "${BOOT_PW}" ]]; then RUN_ENV+=( -e "ADMIN_PASSWORD=${BOOT_PW}" ) elif [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "1" ]] || [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "true" ]]; then diff --git a/server.js b/server.js index fc03afe..8635bd5 100644 --- a/server.js +++ b/server.js @@ -8,9 +8,50 @@ import { fileURLToPath } from "url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PORT = Number(process.env.PORT || 3980); -const CONTAINER = process.env.AWG_CONTAINER || "amnezia-awg2"; -const AWG_CONF = "/opt/amnezia/awg/awg0.conf"; -const CLIENTS_JSON = "/opt/amnezia/awg/clientsTable"; +const PROFILE_COOKIE = "amnezia_prof"; +const SCHEDULER_MS = Number(process.env.SCHEDULE_DISCONNECT_MS || 60_000); + +function parseProfilesFromEnv() { + const raw = process.env.AWG_PROFILES?.trim(); + const fallback = () => [ + { + id: "awg", + label: process.env.AWG_PROFILE_LABEL || "AmneziaWG", + container: process.env.AWG_CONTAINER || "amnezia-awg2", + confPath: process.env.AWG_CONF_PATH || "/opt/amnezia/awg/awg0.conf", + clientsPath: process.env.AWG_CLIENTS_PATH || "/opt/amnezia/awg/clientsTable", + iface: process.env.AWG_IFACE || "awg0", + wgBinary: process.env.AWG_BINARY || "awg", + pskPath: process.env.AWG_PSK_PATH || "/opt/amnezia/awg/wireguard_psk.key", + }, + ]; + if (!raw) return fallback(); + try { + const arr = JSON.parse(raw); + if (!Array.isArray(arr) || arr.length === 0) return fallback(); + return arr + .map((row, i) => ({ + id: String(row.id ?? `p${i}`), + label: String(row.label ?? row.id ?? `Профиль ${i + 1}`), + container: String(row.container ?? ""), + confPath: String(row.confPath ?? row.conf ?? "/opt/amnezia/awg/awg0.conf"), + clientsPath: String(row.clientsPath ?? row.clients ?? "/opt/amnezia/awg/clientsTable"), + iface: String(row.iface ?? row.IFACE ?? "awg0"), + wgBinary: String(row.wgBinary ?? row.binary ?? "awg"), + pskPath: String(row.pskPath ?? row.psk ?? "/opt/amnezia/awg/wireguard_psk.key"), + })) + .filter((p) => p.container); + } catch { + console.warn("AWG_PROFILES: невалидный JSON, используется профиль по умолчанию."); + return fallback(); + } +} + +const PROFILES = parseProfilesFromEnv(); +if (!PROFILES.length) { + console.error("Нет ни одного профиля AWG: укажите container в AWG_PROFILES или переменные по умолчанию."); + process.exit(1); +} const DATA_DIR = process.env.DATA_DIR || "/data"; const PW_FILE = path.join(DATA_DIR, "password.hash"); @@ -152,6 +193,20 @@ function getSessionToken(req) { return null; } +function getProfileCookie(req) { + const raw = req.headers.cookie || ""; + if (!raw) return null; + for (const part of raw.split(";")) { + const s = part.trim(); + const eq = s.indexOf("="); + if (eq === -1) continue; + const k = decodeURIComponent(s.slice(0, eq).trim()); + if (k !== PROFILE_COOKIE) continue; + return decodeURIComponent(s.slice(eq + 1).trim()); + } + return null; +} + function cookieSecureFlag() { return process.env.COOKIE_SECURE === "1" || process.env.COOKIE_SECURE === "true"; } @@ -172,6 +227,14 @@ function clearSessionCookie(res) { ); } +function setProfileCookie(res, profileId) { + const sec = cookieSecureFlag(); + res.setHeader( + "Set-Cookie", + `${PROFILE_COOKIE}=${encodeURIComponent(profileId)}; Max-Age=${31536000}; Path=/; SameSite=Lax${sec ? "; Secure" : ""}` + ); +} + function requireAuth(req, res, next) { const sess = readSession(getSessionToken(req)); if (!sess) { @@ -202,34 +265,91 @@ function execDocker(args, stdin = null) { }); } -async function dockerExec(cmd) { - const { stdout, stderr } = await execDocker([ - "exec", - CONTAINER, - "sh", - "-c", - cmd, - ]); - return stdout + stderr; +function createRuntime(profile) { + const container = profile.container; + const confPath = profile.confPath; + const clientsPath = profile.clientsPath; + const iface = profile.iface; + const wgBinary = profile.wgBinary; + const pskPath = profile.pskPath; + + async function dockerExec(cmd) { + const { stdout, stderr } = await execDocker(["exec", container, "sh", "-c", cmd]); + return stdout + stderr; + } + + async function dockerReadFile(remotePath) { + const { stdout } = await execDocker(["exec", container, "cat", remotePath]); + return stdout; + } + + async function dockerWriteFile(remotePath, content) { + await execDocker( + [ + "exec", + "-i", + container, + "sh", + "-c", + `cat > '${remotePath}.tmp' && mv '${remotePath}.tmp' '${remotePath}'`, + ], + content + ); + } + + async function backupRemoteFiles() { + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + await dockerExec(`cp '${confPath}' '${confPath}.bak-admin-${stamp}' 2>/dev/null || true`); + await dockerExec( + `cp '${clientsPath}' '${clientsPath}.bak-admin-${stamp}' 2>/dev/null || true` + ); + } + + async function applySyncconf() { + await dockerExec( + `wg-quick strip '${confPath}' > /tmp/wg-admin-strip.conf && ${wgBinary} syncconf ${iface} /tmp/wg-admin-strip.conf` + ); + } + + async function loadState() { + const [confText, tableText] = await Promise.all([ + dockerReadFile(confPath), + dockerReadFile(clientsPath), + ]); + const conf = splitAwgConf(confText); + const clients = parseClientsTable(tableText); + const peerByKey = new Map(conf.peers.map((p) => [p.publicKey, p])); + return { confText, conf, clients, peerByKey }; + } + + async function inferPskFromConf(conf) { + if (conf.peers.length) return conf.peers[0].presharedKey; + try { + const text = await dockerReadFile(pskPath); + return text.trim(); + } catch { + return null; + } + } + + return { + profile, + dockerExec, + dockerReadFile, + dockerWriteFile, + backupRemoteFiles, + applySyncconf, + loadState, + inferPskFromConf, + confPath, + clientsPath, + }; } -async function dockerReadFile(remotePath) { - const { stdout } = await execDocker(["exec", CONTAINER, "cat", remotePath]); - return stdout; -} - -async function dockerWriteFile(remotePath, content) { - await execDocker( - [ - "exec", - "-i", - CONTAINER, - "sh", - "-c", - `cat > '${remotePath}.tmp' && mv '${remotePath}.tmp' '${remotePath}'`, - ], - content - ); +function runtimeForRequest(req) { + const wanted = getProfileCookie(req); + const profile = PROFILES.find((p) => p.id === wanted) || PROFILES[0]; + return createRuntime(profile); } function splitAwgConf(text) { @@ -267,40 +387,56 @@ function stringifyClientsTable(rows) { return `${JSON.stringify(rows, null, 4)}\n`; } -async function backupRemoteFiles() { - const stamp = new Date().toISOString().replace(/[:.]/g, "-"); - await dockerExec( - `cp '${AWG_CONF}' '${AWG_CONF}.bak-admin-${stamp}' 2>/dev/null || true` - ); - await dockerExec( - `cp '${CLIENTS_JSON}' '${CLIENTS_JSON}.bak-admin-${stamp}' 2>/dev/null || true` - ); +async function disableClient(rt, clientId, ts) { + await rt.backupRemoteFiles(); + const { conf, clients } = await rt.loadState(); + const peer = conf.peers.find((p) => p.publicKey === clientId); + if (!peer) { + throw new Error("Peer not in config (already disabled?)"); + } + const nextPeers = conf.peers.filter((p) => p.publicKey !== clientId); + const nextConfText = serializeAwgConf(conf.head, nextPeers); + const idx = clients.findIndex((c) => c.clientId === clientId); + if (idx === -1) throw new Error("Client not in clientsTable"); + const ud = { ...(clients[idx].userData || {}) }; + ud.disabled = true; + ud.disabledAt = ts; + ud.lastDisconnectedAt = ts; + delete ud.scheduledTunnelDisconnectAt; + ud.preservedPresharedKey = peer.presharedKey || ud.preservedPresharedKey; + ud.preservedAllowedIPs = peer.allowedIPs || ud.preservedAllowedIPs; + clients[idx] = { ...clients[idx], userData: ud }; + await rt.dockerWriteFile(rt.confPath, nextConfText); + await rt.dockerWriteFile(rt.clientsPath, stringifyClientsTable(clients)); + await rt.applySyncconf(); } -async function applySyncconf() { - await dockerExec( - `wg-quick strip '${AWG_CONF}' > /tmp/wg-admin-strip.conf && awg syncconf awg0 /tmp/wg-admin-strip.conf` - ); +async function processScheduledDisconnects(rt) { + const now = Date.now(); + const { clients, peerByKey } = await rt.loadState(); + const due = []; + for (const c of clients) { + const ud = c.userData || {}; + const iso = ud.scheduledTunnelDisconnectAt; + if (!iso || !peerByKey.get(c.clientId)) continue; + const t = new Date(iso).getTime(); + if (Number.isNaN(t) || t > now) continue; + due.push({ clientId: c.clientId, ts: new Date(iso).toISOString() }); + } + if (!due.length) return; + await rt.backupRemoteFiles(); + for (const { clientId, ts } of due) { + try { + await disableClient(rt, clientId, ts); + } catch (e) { + console.error(`scheduled off ${clientId} [${rt.profile.id}]:`, e); + } + } } -async function loadState() { - const [confText, tableText] = await Promise.all([ - dockerReadFile(AWG_CONF), - dockerReadFile(CLIENTS_JSON), - ]); - const conf = splitAwgConf(confText); - const clients = parseClientsTable(tableText); - const peerByKey = new Map(conf.peers.map((p) => [p.publicKey, p])); - return { confText, conf, clients, peerByKey }; -} - -async function inferPskFromConf(conf) { - if (conf.peers.length) return conf.peers[0].presharedKey; - try { - const text = await dockerReadFile("/opt/amnezia/awg/wireguard_psk.key"); - return text.trim(); - } catch { - return null; +async function processAllScheduledDisconnects() { + for (const profile of PROFILES) { + await processScheduledDisconnects(createRuntime(profile)); } } @@ -404,15 +540,39 @@ app.post("/api/change-password", requireAuth, (req, res) => { res.json({ ok: true, message: "Пароль изменён. Войдите снова." }); }); -app.get("/api/clients", requireAuth, async (_req, res) => { +app.get("/api/protocols", requireAuth, (req, res) => { + const rt = runtimeForRequest(req); + res.json({ + currentId: rt.profile.id, + currentLabel: rt.profile.label, + profiles: PROFILES.map((p) => ({ + id: p.id, + label: p.label, + container: p.container, + })), + }); +}); + +app.post("/api/protocol", requireAuth, (req, res) => { + const id = req.body?.profileId; + if (typeof id !== "string" || !PROFILES.some((p) => p.id === id)) { + res.status(400).json({ error: "Неизвестный profileId" }); + return; + } + setProfileCookie(res, id); + res.json({ ok: true }); +}); + +app.get("/api/clients", requireAuth, async (req, res) => { + const rt = runtimeForRequest(req); try { let wgShow = ""; try { - wgShow = await dockerExec(`awg show awg0`); + wgShow = await rt.dockerExec(`${rt.profile.wgBinary} show ${rt.profile.iface}`); } catch { wgShow = ""; } - const { conf, clients, peerByKey } = await loadState(); + const { conf, clients, peerByKey } = await rt.loadState(); const rows = clients.map((c) => { const id = c.clientId; const peer = peerByKey.get(id); @@ -426,6 +586,7 @@ app.get("/api/clients", requireAuth, async (_req, res) => { disabled: !activeInConf, disabledAt: ud.disabledAt || null, lastDisconnectedAt: ud.lastDisconnectedAt || null, + scheduledTunnelDisconnectAt: ud.scheduledTunnelDisconnectAt || null, creationDate: ud.creationDate || null, latestHandshake: ud.latestHandshake || null, dataReceived: ud.dataReceived || null, @@ -433,7 +594,9 @@ app.get("/api/clients", requireAuth, async (_req, res) => { }; }); res.json({ - container: CONTAINER, + profileId: rt.profile.id, + profileLabel: rt.profile.label, + container: rt.profile.container, protocol: "AmneziaWG", peerCount: conf.peers.length, clients: rows, @@ -446,6 +609,7 @@ app.get("/api/clients", requireAuth, async (_req, res) => { }); app.post("/api/clients/disable", requireAuth, async (req, res) => { + const rt = runtimeForRequest(req); const clientId = req.body?.clientId; if (!clientId) return res.status(400).json({ error: "clientId required" }); let ts; @@ -455,41 +619,25 @@ app.post("/api/clients/disable", requireAuth, async (req, res) => { return res.status(400).json({ error: String(e.message || e) }); } try { - await backupRemoteFiles(); - const { conf, clients } = await loadState(); - const peer = conf.peers.find((p) => p.publicKey === clientId); - if (!peer) { - return res.status(404).json({ error: "Peer not in config (already disabled?)" }); - } - const nextPeers = conf.peers.filter((p) => p.publicKey !== clientId); - const nextConfText = serializeAwgConf(conf.head, nextPeers); - const idx = clients.findIndex((c) => c.clientId === clientId); - if (idx === -1) { - return res.status(404).json({ error: "Client not in clientsTable" }); - } - const ud = { ...(clients[idx].userData || {}) }; - ud.disabled = true; - ud.disabledAt = ts; - ud.lastDisconnectedAt = ts; - ud.preservedPresharedKey = peer.presharedKey || ud.preservedPresharedKey; - ud.preservedAllowedIPs = peer.allowedIPs || ud.preservedAllowedIPs; - clients[idx] = { ...clients[idx], userData: ud }; - await dockerWriteFile(AWG_CONF, nextConfText); - await dockerWriteFile(CLIENTS_JSON, stringifyClientsTable(clients)); - await applySyncconf(); + await disableClient(rt, clientId, ts); res.json({ ok: true }); } catch (e) { + const msg = String(e.message || e); + if (msg.includes("already disabled") || msg.includes("Peer not in config")) { + return res.status(404).json({ error: msg }); + } console.error(e); - res.status(500).json({ error: String(e.message || e) }); + res.status(500).json({ error: msg }); } }); app.post("/api/clients/enable", requireAuth, async (req, res) => { + const rt = runtimeForRequest(req); const clientId = req.body?.clientId; if (!clientId) return res.status(400).json({ error: "clientId required" }); try { - await backupRemoteFiles(); - const { conf, clients } = await loadState(); + await rt.backupRemoteFiles(); + const { conf, clients } = await rt.loadState(); const existing = conf.peers.find((p) => p.publicKey === clientId); if (existing) { return res.status(409).json({ error: "Peer already enabled" }); @@ -502,7 +650,7 @@ app.post("/api/clients/enable", requireAuth, async (req, res) => { const psk = ud.preservedPresharedKey || conf.peers[0]?.presharedKey || - (await inferPskFromConf(conf)); + (await rt.inferPskFromConf(conf)); const ips = ud.preservedAllowedIPs || ud.allowedIps; if (!psk || !ips) { return res.status(400).json({ @@ -519,12 +667,13 @@ AllowedIPs = ${ips}`; const nextConfText = serializeAwgConf(conf.head, nextPeers); delete ud.disabled; delete ud.disabledAt; + delete ud.scheduledTunnelDisconnectAt; delete ud.preservedPresharedKey; delete ud.preservedAllowedIPs; clients[idx] = { ...clients[idx], userData: ud }; - await dockerWriteFile(AWG_CONF, nextConfText); - await dockerWriteFile(CLIENTS_JSON, stringifyClientsTable(clients)); - await applySyncconf(); + await rt.dockerWriteFile(rt.confPath, nextConfText); + await rt.dockerWriteFile(rt.clientsPath, stringifyClientsTable(clients)); + await rt.applySyncconf(); res.json({ ok: true }); } catch (e) { console.error(e); @@ -533,6 +682,7 @@ AllowedIPs = ${ips}`; }); app.post("/api/clients/disconnect-date", requireAuth, async (req, res) => { + const rt = runtimeForRequest(req); const clientId = req.body?.clientId; if (!clientId) return res.status(400).json({ error: "clientId required" }); let iso; @@ -541,18 +691,29 @@ app.post("/api/clients/disconnect-date", requireAuth, async (req, res) => { } catch (e) { return res.status(400).json({ error: String(e.message || e) }); } + const scheduleTunnelDisconnect = Boolean(req.body?.scheduleTunnelDisconnect); try { - const { conf, clients, peerByKey } = await loadState(); + const { clients, peerByKey } = await rt.loadState(); const idx = clients.findIndex((c) => c.clientId === clientId); if (idx === -1) return res.status(404).json({ error: "Client not in clientsTable" }); const peer = peerByKey.get(clientId); const ud = { ...(clients[idx].userData || {}) }; - ud.lastDisconnectedAt = iso; - if (!peer) { - ud.disabledAt = iso; + if (scheduleTunnelDisconnect) { + if (!peer) { + return res.status(400).json({ + error: "Клиент не в туннеле — отложенное отключение недоступно", + }); + } + ud.scheduledTunnelDisconnectAt = iso; + } else { + delete ud.scheduledTunnelDisconnectAt; + ud.lastDisconnectedAt = iso; + if (!peer) { + ud.disabledAt = iso; + } } clients[idx] = { ...clients[idx], userData: ud }; - await dockerWriteFile(CLIENTS_JSON, stringifyClientsTable(clients)); + await rt.dockerWriteFile(rt.clientsPath, stringifyClientsTable(clients)); res.json({ ok: true }); } catch (e) { console.error(e); @@ -561,6 +722,7 @@ app.post("/api/clients/disconnect-date", requireAuth, async (req, res) => { }); app.post("/api/clients/rename", requireAuth, async (req, res) => { + const rt = runtimeForRequest(req); const clientId = req.body?.clientId; const rawName = req.body?.name ?? req.body?.clientName; if (!clientId) return res.status(400).json({ error: "clientId required" }); @@ -573,12 +735,12 @@ app.post("/api/clients/rename", requireAuth, async (req, res) => { return res.status(400).json({ error: "Имя не длиннее 200 символов" }); } try { - const { clients } = await loadState(); + const { clients } = await rt.loadState(); const idx = clients.findIndex((c) => c.clientId === clientId); if (idx === -1) return res.status(404).json({ error: "Client not in clientsTable" }); const ud = { ...(clients[idx].userData || {}), clientName: name }; clients[idx] = { ...clients[idx], userData: ud }; - await dockerWriteFile(CLIENTS_JSON, stringifyClientsTable(clients)); + await rt.dockerWriteFile(rt.clientsPath, stringifyClientsTable(clients)); res.json({ ok: true }); } catch (e) { console.error(e); @@ -587,20 +749,21 @@ app.post("/api/clients/rename", requireAuth, async (req, res) => { }); app.post("/api/clients/delete", requireAuth, async (req, res) => { + const rt = runtimeForRequest(req); const clientId = req.body?.clientId; if (!clientId) return res.status(400).json({ error: "clientId required" }); try { - await backupRemoteFiles(); - const { conf, clients } = await loadState(); + await rt.backupRemoteFiles(); + const { conf, clients } = await rt.loadState(); const nextPeers = conf.peers.filter((p) => p.publicKey !== clientId); const nextClients = clients.filter((c) => c.clientId !== clientId); if (nextClients.length === clients.length) { return res.status(404).json({ error: "Client not in clientsTable" }); } const nextConfText = serializeAwgConf(conf.head, nextPeers); - await dockerWriteFile(AWG_CONF, nextConfText); - await dockerWriteFile(CLIENTS_JSON, stringifyClientsTable(nextClients)); - await applySyncconf(); + await rt.dockerWriteFile(rt.confPath, nextConfText); + await rt.dockerWriteFile(rt.clientsPath, stringifyClientsTable(nextClients)); + await rt.applySyncconf(); res.json({ ok: true }); } catch (e) { console.error(e); @@ -627,5 +790,14 @@ app.use((_req, res) => { }); app.listen(PORT, "0.0.0.0", () => { - console.log(`amnezia-admin on :${PORT} → docker:${CONTAINER}, data:${DATA_DIR}`); + const summary = PROFILES.map((p) => `${p.label}→${p.container}`).join("; "); + console.log(`amnezia-admin on :${PORT} · ${summary} · data:${DATA_DIR}`); }); + +setInterval(() => { + processAllScheduledDisconnects().catch((e) => console.error("scheduleDisconnect:", e)); +}, SCHEDULER_MS); + +setTimeout(() => { + processAllScheduledDisconnects().catch((e) => console.error("scheduleDisconnect:", e)); +}, 4000);