import express from "express"; import { spawn, spawnSync, execFileSync } from "child_process"; import crypto from "crypto"; import path from "path"; import fs from "fs"; import { fileURLToPath } from "url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); /** Из package.json — для мониторинга и проверки, что VPS подняли свежий образ. */ let PANEL_VERSION = "0.0.0"; try { const raw = fs.readFileSync(path.join(__dirname, "package.json"), "utf8"); const v = JSON.parse(raw)?.version; if (typeof v === "string" && v.trim()) PANEL_VERSION = v.trim(); } catch { /* оставить PANEL_VERSION по умолчанию */ } const PORT = Number(process.env.PORT || 3980); const PROFILE_COOKIE = "amnezia_prof"; const SCHEDULER_MS = Number(process.env.SCHEDULE_DISCONNECT_MS || 60_000); /** Если задан, разрешает GET /api/clients/export-config?token=…&clientId=… без сессии (храните секрет только для себя). */ const EXPORT_CONFIG_SECRET = process.env.EXPORT_CONFIG_SECRET?.trim(); function envTruthy(v) { if (typeof v !== "string") return false; const s = v.trim().toLowerCase(); return s === "1" || s === "true" || s === "yes"; } /** Какие блоки веб-интерфейса скрыты: `UI_HIDE_SECTIONS=users,warp,cascade` или `UI_HIDE_USERS` и т.д. */ function resolveUiHidden() { const raw = process.env.UI_HIDE_SECTIONS?.trim(); const set = new Set(); if (raw) { for (const part of raw.split(",")) { const k = part.trim().toLowerCase(); if (k) set.add(k); } } return { users: set.has("users") || envTruthy(process.env.UI_HIDE_USERS), warp: set.has("warp") || envTruthy(process.env.UI_HIDE_WARP), cascade: set.has("cascade") || envTruthy(process.env.UI_HIDE_CASCADE), mtproto: set.has("mtproto") || envTruthy(process.env.UI_HIDE_MTPROTO), }; } const UI_HIDDEN = resolveUiHidden(); const AMNEZIA_EDITION = (process.env.AMNEZIA_EDITION || "pro").trim().toLowerCase(); const IS_COMMUNITY = AMNEZIA_EDITION === "community"; const ALLOW_COMMUNITY_GITHUB_ACTIVATION = envTruthy(process.env.ALLOW_COMMUNITY_GITHUB_ACTIVATION); const COMMUNITY_PRIVATE_INSTALL_SCRIPT_URL = process.env.COMMUNITY_PRIVATE_INSTALL_SCRIPT_URL?.trim() || "https://raw.githubusercontent.com/andrey271192/amnezia_web-pro/main/scripts/install.sh"; const PRIVATE_INSTALL_SCRIPT_MAX_BYTES = Number(process.env.PRIVATE_INSTALL_SCRIPT_MAX_BYTES || 2_097_152) || 2_097_152; /** Флаг выполнения одноразового bash install из UI (держим второй параллельный запрос). */ let communityPrivateInstallBusy = false; const COMMUNITY_SKIP_REMOVE_FREE_BEFORE_PRIVATE_PRO = envTruthy( process.env.COMMUNITY_SKIP_REMOVE_FREE_BEFORE_PRIVATE_PRO, ); const COMMUNITY_DISABLE_DOCKER_CLI_HELPER = envTruthy(process.env.COMMUNITY_DISABLE_DOCKER_CLI_HELPER); const COMMUNITY_UPGRADE_URL = process.env.COMMUNITY_UPGRADE_URL?.trim() || "https://boosty.to/andrey27/purchase/3906453?ssource=DIRECT&share=subscription_link"; const COMMUNITY_UPGRADE_PITCH = process.env.COMMUNITY_UPGRADE_PITCH?.trim() || "В PRO: вкл/выкл клиентов, даты и расписание отключений, переименование, экспорт .conf, каскад, Cloudflare WARP, синхронизация времени хоста. В FREE: удаление клиента с сервера + отдельный раздел установки Telegram MTProto-прокси (Docker-контейнер). Полная сборка AWG-панели — приватный репозиторий amnezia_web-PRO; доступ по подписке Boosty."; const PANEL_FOOTER_TELEGRAM_DEFAULT = "https://t.me/lot_andrey"; function sanitizeHttpsHttpUrl(raw) { const s = String(raw || "").trim(); if (!s) return ""; try { const u = new URL(s); if (u.protocol === "http:" || u.protocol === "https:") return u.href; } catch { /* ignore */ } return ""; } /** Донат + Telegram‑спонсор: только в админ‑панели (не пользовательские лендинги). Переопределяется через env контейнера. */ function panelPromoFooterPayload() { const donateResolved = sanitizeHttpsHttpUrl( process.env.PANEL_FOOTER_DONATE_URL?.trim() || COMMUNITY_UPGRADE_URL.trim(), ) || ""; const telegramResolved = sanitizeHttpsHttpUrl( process.env.PANEL_FOOTER_TELEGRAM_URL?.trim() || PANEL_FOOTER_TELEGRAM_DEFAULT, ) || ""; return { donateUrl: donateResolved, telegramUrl: telegramResolved, donateLabel: process.env.PANEL_FOOTER_DONATE_LABEL?.trim() || "Поддержать проект", telegramLabel: process.env.PANEL_FOOTER_TELEGRAM_LABEL?.trim() || "Telegram‑канал спонсора", promoSubtitle: process.env.PANEL_FOOTER_PROMO_SUBTITLE?.trim() || "Поддержите разработку и канал спонсора — ссылки есть вверху и внизу панели. На пользовательские сайты‑лендинги это не добавляется.", }; } function editionPayload() { return { tier: IS_COMMUNITY ? "community" : "pro", readOnlyClients: IS_COMMUNITY, /** В FREE разрешено удаление записи клиента (остальные мутации — PRO). */ allowDeleteClients: IS_COMMUNITY, upgradeUrl: IS_COMMUNITY ? COMMUNITY_UPGRADE_URL : null, upgradePitch: IS_COMMUNITY ? COMMUNITY_UPGRADE_PITCH : null, showDebugWg: !IS_COMMUNITY, githubActivationAllowed: ALLOW_COMMUNITY_GITHUB_ACTIVATION && IS_COMMUNITY, }; } function effectiveUiHidden() { if (!IS_COMMUNITY) return { ...UI_HIDDEN }; return { users: UI_HIDDEN.users, warp: true, cascade: true, mtproto: UI_HIDDEN.mtproto, }; } function parseProfilesFromEnv() { const raw = process.env.AWG_PROFILES?.trim(); const fallback = () => { const warpDir = (process.env.WARP_DIR || "/opt/warp").replace(/\/+$/, "") || "/opt/warp"; return [ { 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", warpDir, warpConf: process.env.WARP_CONF_PATH || `${warpDir}/warp.conf`, warpClientsList: process.env.WARP_CLIENTS_LIST || `${warpDir}/clients.list`, startScript: process.env.AMNEZIA_START_SCRIPT || "/opt/amnezia/start.sh", }, ]; }; if (!raw) return fallback(); try { const arr = JSON.parse(raw); if (!Array.isArray(arr) || arr.length === 0) return fallback(); return arr .map((row, i) => { const warpDirRaw = row.warpDir ?? "/opt/warp"; const warpDir = String(warpDirRaw).replace(/\/+$/, "") || "/opt/warp"; const warpConf = row.warpConf ? String(row.warpConf) : `${warpDir}/warp.conf`; const warpClientsList = row.warpClientsList ? String(row.warpClientsList) : `${warpDir}/clients.list`; const startScript = String(row.startScript ?? "/opt/amnezia/start.sh"); return { 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"), warpDir, warpConf, warpClientsList, startScript, }; }) .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"); const SECRET_FILE = path.join(DATA_DIR, "session.secret"); /** * Хостовый путь, смонтированный в контейнер панели как DATA_DIR. * Нужен для одноразового docker-cli helper-а: контейнер панели монтирует * `${HOST_DATA_DIR}:/mnt/data`, и оба контейнера видят один и тот же файл логов. * Порядок поиска: * 1) env HOST_DATA_DIR (передаётся install.sh при `docker run`); * 2) docker inspect собственного контейнера (находим Source для Destination=DATA_DIR); * 3) fallback /opt/amnezia-admin-data — типичный путь install.sh. */ function resolveHostDataDir() { const explicit = process.env.HOST_DATA_DIR?.trim(); if (explicit) return explicit; const selfId = (process.env.HOSTNAME || "").trim(); if (selfId) { try { const out = execFileSync( "docker", [ "inspect", "--format", `{{range .Mounts}}{{if eq .Destination "${DATA_DIR}"}}{{.Source}}{{end}}{{end}}`, selfId, ], { stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }, ) .toString() .trim(); if (out) return out; } catch { // } } return "/opt/amnezia-admin-data"; } const HOST_DATA_DIR = resolveHostDataDir(); const SESSION_COOKIE = "amnezia_sess"; const SESSION_MS = 7 * 24 * 60 * 60 * 1000; let passwordHashStored = ""; let sessionSecret = ""; function ensureDataDir() { fs.mkdirSync(DATA_DIR, { recursive: true }); } function hashPassword(password) { const salt = crypto.randomBytes(16); const hash = crypto.scryptSync(password, salt, 64); return `${salt.toString("hex")}:${hash.toString("hex")}`; } function verifyPassword(password, stored) { const parts = stored.split(":"); if (parts.length !== 2) return false; const salt = Buffer.from(parts[0], "hex"); const expected = Buffer.from(parts[1], "hex"); let hash; try { hash = crypto.scryptSync(password, salt, 64); } catch { return false; } if (hash.length !== expected.length) return false; return crypto.timingSafeEqual(hash, expected); } function loadOrCreateSessionSecret() { ensureDataDir(); if (fs.existsSync(SECRET_FILE)) { sessionSecret = fs.readFileSync(SECRET_FILE, "utf8").trim(); if (sessionSecret.length < 32) { throw new Error("session.secret слишком короткий — удалите файл для пересоздания"); } return; } sessionSecret = crypto.randomBytes(32).toString("hex"); fs.writeFileSync(SECRET_FILE, `${sessionSecret}\n`, { mode: 0o600 }); } function rotateSessionSecret() { sessionSecret = crypto.randomBytes(32).toString("hex"); fs.writeFileSync(SECRET_FILE, `${sessionSecret}\n`, { mode: 0o600 }); } function bootstrapPassword() { ensureDataDir(); if (fs.existsSync(PW_FILE)) { passwordHashStored = fs.readFileSync(PW_FILE, "utf8").trim(); if (!passwordHashStored) throw new Error("password.hash пуст"); return; } const bootstrap = process.env.ADMIN_PASSWORD || ""; if (bootstrap) { passwordHashStored = hashPassword(bootstrap); fs.writeFileSync(PW_FILE, `${passwordHashStored}\n`, { mode: 0o600 }); console.warn( "Пароль сохранён в /data/password.hash. Уберите ADMIN_PASSWORD из окружения после первого старта." ); return; } const legacyToken = process.env.ADMIN_TOKEN || ""; if (legacyToken) { passwordHashStored = hashPassword(legacyToken); fs.writeFileSync(PW_FILE, `${passwordHashStored}\n`, { mode: 0o600 }); console.warn( "Миграция: пароль взяли из ADMIN_TOKEN и сохранили в /data/password.hash. Удалите ADMIN_TOKEN из окружения." ); return; } const allowDefault = process.env.ALLOW_DEFAULT_PASSWORD === "1" || process.env.ALLOW_DEFAULT_PASSWORD === "true"; const docPass = process.env.DEFAULT_ADMIN_PASSWORD || "AmneziaAdmin!ChangeMe"; if (allowDefault) { passwordHashStored = hashPassword(docPass); fs.writeFileSync(PW_FILE, `${passwordHashStored}\n`, { mode: 0o600 }); console.warn( "Включён пароль по умолчанию из документации (README). Смените его в панели и отключите ALLOW_DEFAULT_PASSWORD." ); return; } console.error( "Нет пароля: задайте ADMIN_PASSWORD при первом запуске, см. README, или ALLOW_DEFAULT_PASSWORD=1 только для теста." ); process.exit(1); } function signSession(payload) { const body = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url"); const sig = crypto.createHmac("sha256", sessionSecret).update(body).digest("base64url"); return `${body}.${sig}`; } function readSession(token) { if (!token || !sessionSecret) return null; const dot = token.indexOf("."); if (dot === -1) return null; const body = token.slice(0, dot); const sig = token.slice(dot + 1); let expected; try { expected = crypto.createHmac("sha256", sessionSecret).update(body).digest("base64url"); } catch { return null; } try { if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null; } catch { return null; } let payload; try { payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8")); } catch { return null; } if (typeof payload.exp !== "number" || payload.exp < Date.now()) return null; return payload; } function getSessionToken(req) { const raw = req.headers.cookie || ""; for (const part of raw.split(";")) { const p = part.trim(); if (p.startsWith(`${SESSION_COOKIE}=`)) { return decodeURIComponent(p.slice(SESSION_COOKIE.length + 1)); } } 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"; } function setSessionCookie(res, token, maxAgeSec) { const sec = cookieSecureFlag(); res.setHeader( "Set-Cookie", `${SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${maxAgeSec}; Path=/; HttpOnly; SameSite=Lax${sec ? "; Secure" : ""}` ); } function clearSessionCookie(res) { const sec = cookieSecureFlag(); res.setHeader( "Set-Cookie", `${SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${sec ? "; Secure" : ""}` ); } 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) { res.status(401).json({ error: "Unauthorized" }); return; } next(); } function verifyExportQueryToken(token) { if (!EXPORT_CONFIG_SECRET || typeof token !== "string" || !token) return false; const a = Buffer.from(token, "utf8"); const b = Buffer.from(EXPORT_CONFIG_SECRET, "utf8"); if (a.length !== b.length) return false; try { return crypto.timingSafeEqual(a, b); } catch { return false; } } function requireAuthOrExportToken(req, res, next) { if (req.method === "GET" && verifyExportQueryToken(typeof req.query.token === "string" ? req.query.token : "")) { next(); return; } requireAuth(req, res, next); } function rejectCommunityProOnly(res) { res.status(403).json({ error: "Доступно в версии PRO: полное управление клиентами, экспорт .conf, каскад, Cloudflare WARP и синхронизация времени хоста. Раздел MTProto‑прокси Telegram (Docker) в базовой панели уже без PRO.", upgradeRequired: true, upgradeUrl: COMMUNITY_UPGRADE_URL, }); } /** Токен GitHub только из печатаемых ASCII без пробелов/newline (classic ghp_* / github_pat_*). */ function validateGithubBearerToken(tok) { if (typeof tok !== "string") return false; const s = tok.trim(); if (s.length < 20 || s.length > 4096) return false; return /^[!-~]+$/.test(s); } /** Имя контейнера для встраивания в `sh -lc` во внешнем helper (docker rm -f …). */ function shellSafeDockerContainerName(raw, fallback) { const s = typeof raw === "string" ? raw.trim() : ""; if (/^[a-zA-Z][a-zA-Z0-9_.-]{0,127}$/.test(s)) return s; return fallback; } /** * Одноразовый helper (`docker:*-cli`) почти всегда Alpine: нет **`bash`** (#!/usr/bin/env bash у install.sh), **`curl`**, **`openssl`** и **`tar`**. * Без них `exec bash ./install.sh` падает сразу (~код 2/127 и пустое продолжение лога после строки про helper). * Отключить: COMMUNITY_HELPER_SKIP_PREPARE_TOOLS=1 (если свой образ уже со всем необходимым). */ function dockerCliHelperEnsureFetchToolsScript() { if (envTruthy(process.env.COMMUNITY_HELPER_SKIP_PREPARE_TOOLS)) return ""; return ` # bash + curl + openssl + tar для приватного install.sh: # - bash: shebang #!/usr/bin/env bash # - curl: скачивание tar.gz релиза с GitHub # - openssl: генерация первичного пароля/секретов # - tar: распаковка tar.gz if ! command -v bash >/dev/null 2>&1 || ! command -v curl >/dev/null 2>&1 \\ || ! command -v openssl >/dev/null 2>&1 || ! command -v tar >/dev/null 2>&1; then if command -v apk >/dev/null 2>&1; then apk add --no-cache bash curl ca-certificates openssl tar \\ || echo "⚠ helper: apk add bash/curl/openssl/tar failed — install.sh может не запуститься" elif command -v apt-get >/dev/null 2>&1; then export DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null \\ && apt-get install -y -qq bash curl ca-certificates openssl tar \\ || echo "⚠ helper: apt bash/curl/openssl/tar failed — install.sh может не запуститься" fi fi `.trim(); } /** * Пути `conf` / `clientsTable` внутри контейнера Amnezia: не всегда `awg0.conf` * в `/opt/amnezia/awg/` (legacy — `wg0.conf`, второй сервис — подкаталог `awg2`). */ function probeAmneziaTunnelPaths(containerName) { const safe = /^[a-zA-Z0-9_.-]+$/.test(containerName) ? containerName : ""; if (!safe) return null; const script = ` set -eu CONF="" for f in "/opt/amnezia/awg/awg0.conf" "/opt/amnezia/awg/wg0.conf" "/opt/amnezia/awg2/awg0.conf" "/opt/amnezia/awg2/wg0.conf" "/etc/wireguard/wg0.conf" "/etc/wireguard/awg0.conf"; do if [ -f "$f" ]; then CONF="$f"; break; fi done if [ -z "$CONF" ]; then CONF="$(find /opt/amnezia /etc/wireguard -maxdepth 10 \\( -name 'wg0.conf' -o -name 'awg0.conf' \\) 2>/dev/null | head -n 1)" fi [ -n "$CONF" ] && [ -f "$CONF" ] || exit 10 DIR="$(dirname "$CONF")" CLIENTS="" for c in "$DIR/clientsTable" "/opt/amnezia/awg/clientsTable" "/opt/amnezia/awg2/clientsTable"; do if [ -f "$c" ]; then CLIENTS="$c"; break; fi done [ -n "$CLIENTS" ] || exit 11 PSK="" for p in "$DIR/wireguard_psk.key" "/opt/amnezia/awg/wireguard_psk.key"; do if [ -f "$p" ]; then PSK="$p"; break; fi done printf '%s|%s|%s\\n' "$CONF" "$CLIENTS" "$PSK" `.trim(); try { const out = execFileSync("docker", ["exec", containerName, "sh", "-c", script], { stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", timeout: 12000, }).trimEnd(); const line = (out.split(/\r?\n/).find((l) => l.includes("|")) ?? "").trim(); const [confPath = "", clientsPath = "", pskPath = ""] = line.split("|").map((s) => s.trim()); if (!confPath || !clientsPath) return null; return { confPath, clientsPath, ...(pskPath ? { pskPath } : {}), }; } catch { return null; } } /** * Авто-обнаружение профилей `amnezia-awg*` до старта PRO `install.sh`. * Возвращает JSON для `-e AWG_PROFILES=...` или пустую строку при сбое. * * Для каждого контейнера ищутся реальные `confPath`/`clientsTable` (не только дефолт `awg0.conf`). * * Если запущен ровно один `amnezia-awg*`, профиль один — переключатель в UI может не понадаться, * но верные пути всё равно попадают в панель. */ function autoDiscoverAwgProfilesJson() { if (envTruthy(process.env.COMMUNITY_DISABLE_AWG_PROFILES_AUTODETECT)) return ""; if (process.env.AWG_PROFILES?.trim()) return process.env.AWG_PROFILES.trim(); try { const namesOut = execFileSync( "docker", ["ps", "--format", "{{.Names}}", "--filter", "name=^amnezia-awg"], { stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }, ) .toString() .trim(); const names = namesOut.split("\n").map((x) => x.trim()).filter(Boolean); if (names.length === 0) return ""; const entries = names .map((name) => { let firstUdp = null; let portLabel = ""; try { const ports = execFileSync("docker", ["port", name], { stdio: ["ignore", "pipe", "ignore"], timeout: 5000, }) .toString() .trim(); const udpLine = ports.split("\n").find((l) => /\/udp\b/.test(l)); if (udpLine) { const m = udpLine.match(/^(\d+)\/udp/); if (m) { firstUdp = Number(m[1]); portLabel = ` (${m[1]})`; } } } catch { // } const idSuffix = name.replace(/^amnezia-/, "") || name; return { id: idSuffix, label: `AmneziaWG${portLabel}`, container: name, firstUdp: firstUdp ?? 1 << 30, }; }) .sort((a, b) => a.firstUdp - b.firstUdp || a.container.localeCompare(b.container)); const resolved = []; for (const row of entries) { const probed = probeAmneziaTunnelPaths(row.container); if (!probed) { console.warn( `autoDiscover AWG: в контейнере ${row.container} не найдены conf/clientsTable — пропуск авто-AWG_PROFILES`, ); return ""; } resolved.push({ id: row.id, label: row.label, container: row.container, confPath: probed.confPath, clientsPath: probed.clientsPath, ...(probed.pskPath ? { pskPath: probed.pskPath } : {}), }); } return JSON.stringify(resolved); } catch { return ""; } } function privateInstallScriptUrlLogged() { try { const u = new URL(COMMUNITY_PRIVATE_INSTALL_SCRIPT_URL); return `${u.hostname}${u.pathname}`; } catch { return "(invalid PRIVATE_INSTALL_SCRIPT_URL)"; } } function requireProTier(_req, res, next) { if (!IS_COMMUNITY) { next(); return; } rejectCommunityProOnly(res); } function runtimeFromExportRequest(req) { const qPid = typeof req.query.profileId === "string" ? req.query.profileId.trim() : ""; const bodyPid = req.method === "POST" && typeof req.body?.profileId === "string" ? req.body.profileId.trim() : ""; const pid = qPid || bodyPid; if (pid) { const p = PROFILES.find((x) => x.id === pid); if (p) return createRuntime(p); } return runtimeForRequest(req); } function execDocker(args, stdin = null) { return new Promise((resolve, reject) => { const child = spawn("docker", args, { stdio: ["pipe", "pipe", "pipe"] }); let out = ""; let err = ""; child.stdout.on("data", (c) => (out += c)); child.stderr.on("data", (c) => (err += c)); child.on("error", reject); child.on("close", (code) => { if (code === 0) resolve({ stdout: out, stderr: err }); else reject(new Error(err.trim() || out.trim() || `exit ${code}`)); }); if (stdin != null) { child.stdin.write(stdin); child.stdin.end(); } else { child.stdin.end(); } }); } /** Запуск `sh -s` внутри контейнера со скриптом по stdin (многострочный shell без экранирования). */ function dockerExecStdin(container, script) { return new Promise((resolve, reject) => { const child = spawn("docker", ["exec", "-i", container, "sh", "-s"], { stdio: ["pipe", "pipe", "pipe"], }); let out = ""; let err = ""; child.stdout.on("data", (c) => (out += c)); child.stderr.on("data", (c) => (err += c)); child.on("error", reject); child.on("close", (code) => { if (code === 0) resolve({ stdout: out, stderr: err }); else reject(new Error(err.trim() || out.trim() || `exit ${code}`)); }); child.stdin.write(script); child.stdin.end(); }); } function assertSafeUnixPath(p) { const s = String(p).trim(); if (!/^\/[a-zA-Z0-9_/.-]+$/.test(s)) { throw new Error(`Недопустимый путь: ${p}`); } return s; } /** Разрешённые адреса клиента AmneziaWG для правил WARP (обычно одно значение с /32). */ function assertAllowedIpCidr(token) { const s = String(token).trim(); if (!/^(\d{1,3}\.){3}\d{1,3}\/\d{1,3}$/.test(s)) { throw new Error(`Недопустимый AllowedIPs для WARP: ${token}`); } return s; } function peerAllowedIpTokens(peer) { const raw = peer?.allowedIPs || ""; return raw .split(",") .map((x) => x.trim()) .filter(Boolean); } async function dockerRestartContainer(container) { await execDocker(["restart", container]); for (let i = 0; i < 24; i++) { try { await execDocker(["exec", container, "sh", "-c", "true"]); return; } catch { await new Promise((r) => setTimeout(r, 500)); } } throw new Error("Контейнер не ответил после restart"); } async function warpFileExists(rt, remotePath) { try { await execDocker(["exec", rt.profile.container, "test", "-f", remotePath]); return true; } catch { return false; } } async function warpInterfaceUp(rt) { try { await execDocker(["exec", rt.profile.container, "ip", "addr", "show", "warp"]); return true; } catch { return false; } } async function warpLoadSelectedIps(rt) { try { const raw = await rt.dockerReadFile(rt.profile.warpClientsList); const lines = raw.split("\n").map((l) => l.trim()).filter(Boolean); return lines.map((l) => assertAllowedIpCidr(l)); } catch { return []; } } async function warpSaveSelectedIps(rt, ips) { const uniq = [...new Set(ips.map((x) => assertAllowedIpCidr(x)))]; const content = uniq.length ? `${uniq.join("\n")}\n` : ""; await rt.dockerExec(`mkdir -p '${rt.profile.warpDir}'`); await rt.dockerWriteFile(rt.profile.warpClientsList, content); } async function warpCleanupRules(rt) { const sh = `#!/bin/sh set +e ip rule | awk '/lookup 100/ {print \$1}' | sed 's/://g' | sort -rn | while read -r pr; do ip rule del priority "\$pr" 2>/dev/null || true done iptables -t nat -S POSTROUTING 2>/dev/null | grep -- '-o warp -j MASQUERADE' | while read -r line; do rule=$(echo "\$line" | sed 's/^-A /-D /') iptables -t nat \$rule 2>/dev/null || true done ip route flush table 100 2>/dev/null || true exit 0 `; try { await dockerExecStdin(rt.profile.container, sh); } catch { /* ignore */ } } async function warpApplyRouting(rt, ips) { await warpCleanupRules(rt); const list = ips.map((x) => assertAllowedIpCidr(x)); if (!list.length) return; await rt.dockerExec( "ip route add default dev warp table 100 2>/dev/null || ip route replace default dev warp table 100 2>/dev/null || true", ); let prio = 100; for (const ip of list) { await rt.dockerExec( `ip rule add from ${ip} table 100 priority ${prio} 2>/dev/null || true && ` + `(iptables -t nat -C POSTROUTING -s ${ip} -o warp -j MASQUERADE 2>/dev/null || ` + `iptables -t nat -I POSTROUTING 1 -s ${ip} -o warp -j MASQUERADE)`, ); prio += 1; } } function buildWarpBootBlock(warpConf, ips) { assertSafeUnixPath(warpConf); const list = ips.map((x) => assertAllowedIpCidr(x)); let routing = ""; if (list.length > 0) { routing += "ip route add default dev warp table 100 2>/dev/null || ip route replace default dev warp table 100 2>/dev/null || true\n\n"; let prio = 100; for (const ip of list) { routing += `ip rule add from ${ip} table 100 priority ${prio} 2>/dev/null || true\n`; routing += `iptables -t nat -C POSTROUTING -s ${ip} -o warp -j MASQUERADE 2>/dev/null || iptables -t nat -I POSTROUTING 1 -s ${ip} -o warp -j MASQUERADE\n`; prio += 1; } routing += "\n"; } return ( "# --- WARP-MANAGER BEGIN ---\n\n" + `if [ -f '${warpConf}' ]; then\n` + ` wg-quick up '${warpConf}' || true\n` + ` sleep 3\n` + `fi\n\n` + routing + "# --- WARP-MANAGER END ---\n" ); } async function warpPatchStartSh(rt, ips) { const startScript = rt.profile.startScript; assertSafeUnixPath(startScript); const block = buildWarpBootBlock(rt.profile.warpConf, ips); const delim = `WARPBLK_${crypto.randomBytes(8).toString("hex")}`; if (block.includes(delim)) { throw new Error("internal delimiter collision"); } const sq = startScript.replace(/'/g, "'\\''"); const remote = [ "#!/bin/sh", "set -e", `START_SH='${sq}'`, `BLOCK=$(cat <<'${delim}'`, block.trimEnd(), delim, ")", 'if grep -qF \'# --- WARP-MANAGER BEGIN ---\' "$START_SH" 2>/dev/null; then', ' sed -i \'/# --- WARP-MANAGER BEGIN ---/,/# --- WARP-MANAGER END ---/d\' "$START_SH"', "fi", 'if grep -qF \'tail -f /dev/null\' "$START_SH"; then', " tmpfile=$(mktemp)", " while IFS= read -r line; do", ' if echo "$line" | grep -qF \'tail -f /dev/null\'; then', ' printf \'%s\\n\' "$BLOCK"', " fi", ' printf \'%s\\n\' "$line"', ' done < "$START_SH" > "$tmpfile"', ' mv "$tmpfile" "$START_SH"', ' chmod +x "$START_SH"', "else", ' printf \'\\n%s\\n\' "$BLOCK" >> "$START_SH"', ' chmod +x "$START_SH"', "fi", "", ].join("\n"); await dockerExecStdin(rt.profile.container, remote); } async function warpPersistAndRestart(rt, selectedIps) { await rt.backupRemoteFiles(); await warpSaveSelectedIps(rt, selectedIps); await warpApplyRouting(rt, selectedIps); await warpPatchStartSh(rt, selectedIps); await dockerRestartContainer(rt.profile.container); } function activePeerAllowedIpSet(conf) { const set = new Set(); for (const p of conf.peers) { for (const t of peerAllowedIpTokens(p)) { try { set.add(assertAllowedIpCidr(t)); } catch { /* только ipv4 /cidr */ } } } return set; } async function warpSummaryForRt(rt) { try { assertSafeUnixPath(rt.profile.warpConf); assertSafeUnixPath(rt.profile.warpClientsList); assertSafeUnixPath(rt.profile.warpDir); assertSafeUnixPath(rt.profile.startScript); } catch { return { supported: false }; } let installed = false; try { installed = await warpFileExists(rt, rt.profile.warpConf); } catch { installed = false; } const running = installed ? await warpInterfaceUp(rt) : false; let exitIp = null; if (running) { try { const out = await rt.dockerExec( "curl -fsS --interface warp --connect-timeout 4 https://ifconfig.me 2>/dev/null || true", ); const t = out.trim(); exitIp = t || null; } catch { exitIp = null; } } let selectedAllowedIps = []; if (installed) { try { selectedAllowedIps = await warpLoadSelectedIps(rt); } catch { selectedAllowedIps = []; } } let wgShowWarp = ""; if (installed && running) { try { wgShowWarp = await rt.dockerExec("wg show warp 2>/dev/null || true"); } catch { wgShowWarp = ""; } } return { supported: true, installed, running, exitIp, wgShowWarp, selectedAllowedIps, paths: { warpConf: rt.profile.warpConf, clientsList: rt.profile.warpClientsList, warpDir: rt.profile.warpDir, startScript: rt.profile.startScript, }, }; } function peerUsesWarp(peer, selectedSet) { if (!peer || !selectedSet.size) return false; for (const t of peerAllowedIpTokens(peer)) { try { if (selectedSet.has(assertAllowedIpCidr(t))) return true; } catch { /* ipv6 и др. */ } } return false; } /** Имя интерфейса для `awg|wg syncconf` и `… show`: при Legacy часто `/…/wg0.conf`, а в env остаётся дефолт `awg0`. */ function resolveTunnelIface(profile) { const cp = String(profile.confPath || ""); const m = cp.match(/\/([^/.]+)\.conf$/); if (m) { const stem = m[1]; const low = stem.toLowerCase(); if (low === "wg0" || low === "awg0") return stem; } return profile.iface; } function createRuntime(profile) { const container = profile.container; const confPath = profile.confPath; const clientsPath = profile.clientsPath; const iface = resolveTunnelIface(profile); 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( `chmod 600 '${confPath}' 2>/dev/null || true` + `; 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, tunnelIface: iface, dockerExec, dockerReadFile, dockerWriteFile, backupRemoteFiles, applySyncconf, loadState, inferPskFromConf, confPath, clientsPath, }; } function runtimeForRequest(req) { const wanted = getProfileCookie(req); const profile = PROFILES.find((p) => p.id === wanted) || PROFILES[0]; return createRuntime(profile); } function splitAwgConf(text) { const t = text.replace(/\r\n/g, "\n"); const parts = t.split(/(?=^\[Peer\])/m); const head = parts[0].trimEnd(); const peers = parts.slice(1).map(parsePeerBlock).filter((p) => p.publicKey); return { head, peers }; } function parsePeerBlock(block) { const lineMap = (key) => { const m = block.match(new RegExp(`^${key}\\s*=\\s*(.+)$`, "m")); return m ? m[1].trim() : null; }; const publicKey = lineMap("PublicKey"); const presharedKey = lineMap("PresharedKey"); const allowedIPs = lineMap("AllowedIPs"); const raw = block.trimEnd(); return { raw, publicKey, presharedKey, allowedIPs }; } function serializeAwgConf(head, peers) { const body = peers.map((p) => p.raw.trim()).join("\n\n"); return (body ? `${head}\n\n${body}\n` : `${head}\n`).replace(/\n+$/, "\n"); } function parseClientsTable(raw) { const data = JSON.parse(raw); if (!Array.isArray(data)) throw new Error("clientsTable is not an array"); return data; } function stringifyClientsTable(rows) { return `${JSON.stringify(rows, null, 4)}\n`; } /** Совпадает с defaults Amnezia Desktop (protocolConstants awg, desktop MTU). */ const AWG_EXPORT_DEFAULTS = { Jc: "3", Jmin: "10", Jmax: "30", S1: "15", S2: "18", S3: "20", S4: "23", H1: "1020325451", H2: "3288052141", H3: "1766607858", H4: "2528465083", I1: "", I2: "", I3: "", I4: "", I5: "", }; function parseLastConfigFromClientRow(row) { const ud = row?.userData; if (!ud || typeof ud !== "object") return null; let raw = ud.last_config ?? ud.lastConfig; if (typeof raw === "string") { raw = raw.trim(); if (!raw) return null; try { return JSON.parse(raw); } catch { return null; } } if (raw && typeof raw === "object") return raw; return null; } function clientHasExportableLastConfig(row) { const lc = parseLastConfigFromClientRow(row); if (!lc) return false; if (String(lc.config ?? lc.nativeConfig ?? "").trim()) return true; const priv = lc.client_priv_key || lc.clientPrivKey; return Boolean(priv && typeof priv === "string"); } function pickLc(lc, ...keys) { for (const k of keys) { const v = lc[k]; if (v != null && v !== "") return v; } return undefined; } function parseInterfaceKeyValues(head) { const out = {}; for (const line of String(head).split("\n")) { const t = line.trim(); if (!t || t.startsWith("#")) continue; const eq = t.indexOf("="); if (eq === -1) continue; const k = t.slice(0, eq).trim(); const v = t.slice(eq + 1).trim(); out[k] = v; } return out; } /** Имя файла только из ASCII — иначе Node отклоняет заголовок Content-Disposition. */ function safeExportFilenamePart(name, fallback) { const toAsciiToken = (s) => String(s ?? "") .normalize("NFKD") .replace(/[^\x20-\x7E]/g, "") .replace(/[^a-zA-Z0-9._-]/g, "_") .replace(/_+/g, "_") .replace(/^_|_$/g, "") .slice(0, 80); return toAsciiToken(name) || toAsciiToken(fallback) || "client"; } function formatExportAllowedIps(lc, fallback = "0.0.0.0/0, ::/0") { const v = lc.allowed_ips ?? lc.allowedIps; if (Array.isArray(v)) { const joined = v.map(String).join(", "); return joined.trim() || fallback; } if (typeof v === "string" && v.trim()) return v.trim(); return fallback; } async function wgPubkeyFromPrivate(rt, privKeyB64) { const key = String(privKeyB64).trim(); if (!/^[A-Za-z0-9+/=_-]+$/.test(key)) { throw new Error("Некорректный формат приватного ключа сервера в awg0.conf"); } const q = key.replace(/'/g, `'\\''`); const out = await rt.dockerExec(`printf '%s\\n' '${q}' | ${rt.profile.wgBinary} pubkey`); const pub = out.trim().split(/\s+/)[0]; if (!pub) throw new Error("Не удалось получить публичный ключ сервера (wg pubkey)."); return pub; } function tunnelClientIpv4(peer, lc, row) { const fromLc = pickLc(lc, "client_ip", "clientIp"); if (fromLc) return String(fromLc).replace(/\/\d+$/, "").trim(); if (peer?.allowedIPs) { const m = String(peer.allowedIPs).match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/); if (m) return m[1]; } const ud = row?.userData || {}; const udIp = ud.allowedIps || ud.preservedAllowedIPs; if (udIp) { const m = String(udIp).match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/); if (m) return m[1]; } throw new Error( "Нет client_ip в last_config и не удалось взять IPv4 из AllowedIPs peer или из записи клиента (выключен без сохранённого адреса).", ); } function resolveExportEndpointHost(lc, req) { const env = process.env.CLIENT_CONFIG_ENDPOINT?.trim(); if (env) return env; const hn = pickLc(lc, "hostName", "hostname", "host"); if (hn && String(hn).trim()) return String(hn).trim(); const h = req.headers.host; if (h && typeof h === "string") { const hostPart = h.split(":")[0].trim(); if (hostPart && hostPart !== "localhost") return hostPart; } throw new Error( "Не удалось определить Endpoint. Задайте CLIENT_CONFIG_ENDPOINT для контейнера панели (публичный IP или DNS VPS) или hostName в last_config клиента.", ); } async function buildClientConfExport(rt, lc, ifaceMap, req, row, conf) { const native = String(lc.config ?? lc.nativeConfig ?? "").trim(); if (native) return native; const priv = pickLc(lc, "client_priv_key", "clientPrivKey"); if (!priv || typeof priv !== "string") { throw new Error( "В last_config нет готового текста (config) и нет client_priv_key — восстановить .conf с сервера нельзя.", ); } const peer = conf.peers.find((p) => p.publicKey === row.clientId); const tunnelIp = tunnelClientIpv4(peer || {}, lc, row); let serverPub = pickLc(lc, "server_pub_key", "serverPubKey"); if (!serverPub && ifaceMap.PrivateKey) { serverPub = await wgPubkeyFromPrivate(rt, ifaceMap.PrivateKey); } if (!serverPub) { throw new Error("Нет server_pub_key в last_config и PrivateKey в секции [Interface] сервера."); } const psk = pickLc(lc, "psk_key", "pskKey"); if (!psk || typeof psk !== "string") { throw new Error("В last_config нет psk_key (общий ключ с сервером)."); } const endpointHost = resolveExportEndpointHost(lc, req); const listenPort = ifaceMap.ListenPort ? Number(ifaceMap.ListenPort) : NaN; const portNum = Number(pickLc(lc, "port")) || (Number.isFinite(listenPort) ? listenPort : NaN); const defaultPort = rt.profile.wgBinary === "awg" ? 55424 : 51820; const port = Number.isFinite(portNum) && portNum > 0 ? portNum : defaultPort; const dns1 = String(pickLc(lc, "dns1") || process.env.CLIENT_EXPORT_DNS1?.trim() || "8.8.8.8"); const dns2 = String(pickLc(lc, "dns2") || process.env.CLIENT_EXPORT_DNS2?.trim() || "8.8.4.4"); const peerAllowed = formatExportAllowedIps(lc); const keepAlive = String(pickLc(lc, "persistent_keep_alive", "persistentKeepAlive") || "25"); const mtuVal = pickLc(lc, "mtu", "MTU"); const mtuLine = mtuVal ? `MTU = ${String(mtuVal).trim()}\n` : ""; if (rt.profile.wgBinary === "awg") { const Jc = String(pickLc(lc, "Jc", "junk_packet_count", "junkPacketCount") ?? AWG_EXPORT_DEFAULTS.Jc); const Jmin = String(pickLc(lc, "Jmin", "junk_packet_min_size", "junkPacketMinSize") ?? AWG_EXPORT_DEFAULTS.Jmin); const Jmax = String(pickLc(lc, "Jmax", "junk_packet_max_size", "junkPacketMaxSize") ?? AWG_EXPORT_DEFAULTS.Jmax); const S1 = String(pickLc(lc, "S1", "init_packet_junk_size", "initPacketJunkSize") ?? AWG_EXPORT_DEFAULTS.S1); const S2 = String(pickLc(lc, "S2", "response_packet_junk_size", "responsePacketJunkSize") ?? AWG_EXPORT_DEFAULTS.S2); const S3 = String( pickLc(lc, "S3", "cookie_reply_packet_junk_size", "cookieReplyPacketJunkSize") ?? AWG_EXPORT_DEFAULTS.S3, ); const S4 = String( pickLc(lc, "S4", "transport_packet_junk_size", "transportPacketJunkSize") ?? AWG_EXPORT_DEFAULTS.S4, ); const H1 = String(pickLc(lc, "H1", "init_packet_magic_header", "initPacketMagicHeader") ?? AWG_EXPORT_DEFAULTS.H1); const H2 = String( pickLc(lc, "H2", "response_packet_magic_header", "responsePacketMagicHeader") ?? AWG_EXPORT_DEFAULTS.H2, ); const H3 = String( pickLc(lc, "H3", "underload_packet_magic_header", "underloadPacketMagicHeader") ?? AWG_EXPORT_DEFAULTS.H3, ); const H4 = String( pickLc(lc, "H4", "transport_packet_magic_header", "transportPacketMagicHeader") ?? AWG_EXPORT_DEFAULTS.H4, ); const I1 = String(pickLc(lc, "I1", "special_junk_1", "specialJunk1") ?? AWG_EXPORT_DEFAULTS.I1); const I2 = String(pickLc(lc, "I2", "special_junk_2", "specialJunk2") ?? AWG_EXPORT_DEFAULTS.I2); const I3 = String(pickLc(lc, "I3", "special_junk_3", "specialJunk3") ?? AWG_EXPORT_DEFAULTS.I3); const I4 = String(pickLc(lc, "I4", "special_junk_4", "specialJunk4") ?? AWG_EXPORT_DEFAULTS.I4); const I5 = String(pickLc(lc, "I5", "special_junk_5", "specialJunk5") ?? AWG_EXPORT_DEFAULTS.I5); return `[Interface] Address = ${tunnelIp}/32 DNS = ${dns1}, ${dns2} PrivateKey = ${priv.trim()} Jc = ${Jc} Jmin = ${Jmin} Jmax = ${Jmax} S1 = ${S1} S2 = ${S2} S3 = ${S3} S4 = ${S4} H1 = ${H1} H2 = ${H2} H3 = ${H3} H4 = ${H4} I1 = ${I1} I2 = ${I2} I3 = ${I3} I4 = ${I4} I5 = ${I5} ${mtuLine}[Peer] PublicKey = ${String(serverPub).trim()} PresharedKey = ${String(psk).trim()} AllowedIPs = ${peerAllowed} Endpoint = ${endpointHost}:${port} PersistentKeepalive = ${keepAlive} `; } return `[Interface] Address = ${tunnelIp}/32 DNS = ${dns1}, ${dns2} PrivateKey = ${priv.trim()} ${mtuLine}[Peer] PublicKey = ${String(serverPub).trim()} PresharedKey = ${String(psk).trim()} AllowedIPs = ${peerAllowed} Endpoint = ${endpointHost}:${port} PersistentKeepalive = ${keepAlive} `; } function assertCascadeEndpointHost(raw) { const s = String(raw ?? "").trim(); if (!s || s.length > 253) { throw new Error("Укажите IP или DNS для Endpoint (куда клиент будет стучаться в каскаде)."); } if (/[\s<>\"']/.test(s)) { throw new Error("Недопустимые символы в Endpoint."); } return s; } function parseIpv4ToParts(ip) { const m = String(ip).trim().match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); if (!m) return null; const o = [1, 2, 3, 4].map((i) => parseInt(m[i], 10)); if (o.some((x) => x > 255 || Number.isNaN(x))) return null; return o; } async function awgGenKeypair(rt) { const privOut = await rt.dockerExec(`${rt.profile.wgBinary} genkey`); const priv = privOut.trim().split(/\s+/)[0]; if (!priv || !/^[A-Za-z0-9+/=_-]+$/.test(priv)) { throw new Error("Не удалось сгенерировать ключ клиента (genkey)."); } const q = priv.replace(/'/g, `'\\''`); const pubOut = await rt.dockerExec(`printf '%s\\n' '${q}' | ${rt.profile.wgBinary} pubkey`); const pub = pubOut.trim().split(/\s+/)[0]; if (!pub) throw new Error("Не удалось получить публичный ключ клиента."); return { priv, pub }; } function obfuscationFieldsFromServerHead(ifaceMap) { const keys = ["Jc", "Jmin", "Jmax", "S1", "S2", "S3", "S4", "H1", "H2", "H3", "H4", "I1", "I2", "I3", "I4", "I5"]; const out = {}; for (const k of keys) { const v = ifaceMap[k]; if (v != null && String(v).trim() !== "") { out[k] = String(v).trim(); } } return out; } function collectUsedTunnelIps(conf) { const used = new Set(); for (const p of conf.peers) { const raw = p.allowedIPs || ""; const re = /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?:\/\d+)?/g; let m; while ((m = re.exec(raw)) !== null) { used.add(m[1]); } } return used; } function inferSubnetPrefixFromConf(conf, ifaceMap) { const addrRaw = ifaceMap.Address || ifaceMap.address; if (addrRaw) { const chunk = String(addrRaw).split(",")[0].trim(); const parts = parseIpv4ToParts(chunk.split("/")[0]); if (parts) { return `${parts[0]}.${parts[1]}.${parts[2]}`; } } for (const p of conf.peers) { const m = String(p.allowedIPs || "").match(/(\d{1,3}\.\d{1,3}\.\d{1,3})\.\d{1,3}/); if (m) return m[1]; } return "10.8.1"; } function suggestNextTunnelIp(conf, ifaceMap) { const prefix = inferSubnetPrefixFromConf(conf, ifaceMap); const used = collectUsedTunnelIps(conf); let maxLast = 1; for (const ip of used) { if (!ip.startsWith(`${prefix}.`)) continue; const last = parseInt(ip.slice(prefix.length + 1), 10); if (!Number.isNaN(last)) maxLast = Math.max(maxLast, last); } for (let last = Math.max(2, maxLast + 1); last <= 254; last++) { const candidate = `${prefix}.${last}`; if (!used.has(candidate)) return candidate; } throw new Error("Не нашёл свободный IPv4 в подсети VPN для нового клиента."); } function normalizeCascadeTunnelIp(conf, ifaceMap, requested) { const prefix = inferSubnetPrefixFromConf(conf, ifaceMap); if (!requested || !String(requested).trim()) { return suggestNextTunnelIp(conf, ifaceMap); } const stripped = String(requested).trim().replace(/\/32$/i, ""); const parts = parseIpv4ToParts(stripped); if (!parts) { throw new Error("Некорректный IP туннеля (ожидается IPv4, например 10.8.1.10)."); } const triple = `${parts[0]}.${parts[1]}.${parts[2]}`; if (triple !== prefix) { throw new Error(`IP клиента должен быть в подсети ${prefix}.x как у остальных клиентов этого инстанса.`); } const full = `${parts[0]}.${parts[1]}.${parts[2]}.${parts[3]}`; const used = collectUsedTunnelIps(conf); if (used.has(full)) { throw new Error(`Адрес ${full} уже занят другим клиентом.`); } return full; } 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 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 processAllScheduledDisconnects() { for (const profile of PROFILES) { await processScheduledDisconnects(createRuntime(profile)); } } /** ISO string; пустое значение → текущий момент */ function normalizeDisconnectedAtOptional(raw) { if (raw == null || raw === "") return new Date().toISOString(); const d = new Date(raw); if (Number.isNaN(d.getTime())) { throw new Error("Некорректная дата disconnectedAt"); } return d.toISOString(); } function requireDisconnectedAt(raw) { if (raw == null || raw === "") { throw new Error("Укажите дату отключения"); } const d = new Date(raw); if (Number.isNaN(d.getTime())) { throw new Error("Некорректная дата"); } return d.toISOString(); } /** Пояс для строки «Сервер»: переменная TZ контейнера или значение из Intl (часто UTC в Docker). Без подмены под пояс браузера. */ function resolveServerClockTimeZone() { const tzEnv = process.env.TZ?.trim(); if (tzEnv) return tzEnv; try { return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { return "UTC"; } } /** Смещение от UTC в минутах для IANA-пояса в данный момент (через GMT± из Intl). */ function offsetMinutesFromUtc(timeZone, date) { try { const dtf = new Intl.DateTimeFormat("en-US", { timeZone, timeZoneName: "longOffset", }); const parts = dtf.formatToParts(date); let raw = parts.find((p) => p.type === "timeZoneName")?.value || ""; raw = raw.replace(/\u2212/g, "-").trim(); let m = raw.match(/^GMT([+-])(\d{1,2})(?::(\d{2}))?$/i); if (!m) { m = raw.match(/^([+-])(\d{2}):(\d{2})$/); if (m) { const sign = m[1] === "-" ? -1 : 1; const h = parseInt(m[2], 10); const min = parseInt(m[3], 10); return sign * (h * 60 + min); } return 0; } const sign = m[1] === "-" ? -1 : 1; const h = parseInt(m[2], 10); const min = m[3] ? parseInt(m[3], 10) : 0; return sign * (h * 60 + min); } catch { return 0; } } function buildZoneCompare(serverTz, browserTz, now) { if (!browserTz) { return { sameZone: null, hint: "", diffMinutes: null }; } if (browserTz === serverTz) { return { sameZone: true, hint: "Пояс браузера совпадает с поясом строки «Сервер» — часы совпадут.", diffMinutes: 0, }; } const so = offsetMinutesFromUtc(serverTz, now); const bo = offsetMinutesFromUtc(browserTz, now); const diffMin = bo - so; const abs = Math.abs(diffMin); const h = Math.floor(abs / 60); const m = abs % 60; const ahead = diffMin > 0; const hint = ahead ? `Ваше место (${browserTz}): на ${h} ч ${m} мин «впереди» строки «Сервер» (${serverTz}) при одном UTC.` : `Ваше место (${browserTz}): на ${h} ч ${m} мин «позже» пояса сервера (${serverTz}).`; return { sameZone: false, hint, diffMinutes: diffMin }; } function sshpassBinaryPath() { for (const p of ["/usr/bin/sshpass", "/usr/local/bin/sshpass"]) { try { fs.accessSync(p, fs.constants.X_OK); return p; } catch { /* next */ } } return null; } function hostTimeSyncConfigured() { if (process.env.TIME_SYNC_DISABLED === "1" || process.env.TIME_SYNC_DISABLED === "true") { return false; } return !!sshpassBinaryPath(); } function sshRootRun(password, host, remoteCmd) { const bin = sshpassBinaryPath(); if (!bin) { return Promise.reject(new Error("sshpass не установлен")); } return new Promise((resolve, reject) => { const args = [ "-p", password, "ssh", "-oStrictHostKeyChecking=no", "-oUserKnownHostsFile=/dev/null", "-oConnectTimeout=15", "-oPreferredAuthentications=password", "-oPubkeyAuthentication=no", `root@${host}`, remoteCmd, ]; const child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] }); let out = ""; let err = ""; child.stdout.on("data", (c) => (out += c)); child.stderr.on("data", (c) => (err += c)); child.on("error", reject); child.on("close", (code) => { if (code === 0) resolve(out.trim()); else reject(new Error(err.trim() || out.trim() || `ssh код ${code}`)); }); }); } ensureDataDir(); loadOrCreateSessionSecret(); bootstrapPassword(); /** Сообщение для отключённых через UI_HIDE разделов WARP / каскада. */ const MSG_UI_WARP_OFF = "Раздел Cloudflare WARP отключён на этом сервере (UI_HIDE_SECTIONS / UI_HIDE_WARP)."; const MSG_UI_CASCADE_OFF = "Каскад отключён на этом сервере (UI_HIDE_SECTIONS / UI_HIDE_CASCADE)."; const MSG_UI_MTProto_OFF = "Раздел MTProto отключён (UI_HIDE_SECTIONS включает mtproto или задан UI_HIDE_MTPROTO=1)."; const MTPRO_CONTAINER = (process.env.MTPRO_PROXY_CONTAINER || "mtproto-proxy").trim(); const MTPRO_IMAGE = (process.env.MTPRO_PROXY_IMAGE || "telegrammessenger/proxy:latest").trim(); const MTPRO_INTERNAL_PORT = Number(process.env.MTPRO_INTERNAL_PORT || 443) || 443; const MTPRO_PUBLISH_PORT_DEFAULT = (() => { const n = Number.parseInt(process.env.MTPRO_PUBLISH_PORT || "8443", 10); return Number.isFinite(n) && n > 0 ? n : 8443; })(); const MTPRO_PUBLISH_BIND = (process.env.MTPRO_PUBLISH_BIND || "0.0.0.0").trim() || "0.0.0.0"; let mtprotoInstallBusy = false; function dockerSpawnSync(args, timeoutMs = 180_000) { const r = spawnSync("docker", args, { encoding: "utf8", maxBuffer: 5 * 1024 * 1024, timeout: timeoutMs, stdio: ["ignore", "pipe", "pipe"], }); return { code: typeof r.status === "number" ? r.status : 1, stdout: String(r.stdout || ""), stderr: String(r.stderr || ""), }; } function envArrayToMap(envArr) { const out = {}; if (!Array.isArray(envArr)) return out; for (const line of envArr) { const i = line.indexOf("="); if (i <= 0) continue; out[line.slice(0, i)] = line.slice(i + 1); } return out; } function mtprotoParsedInspect() { const r = dockerSpawnSync(["inspect", MTPRO_CONTAINER], 20_000); if (r.code !== 0) return null; try { const arr = JSON.parse(r.stdout); return arr?.[0] ?? null; } catch { return null; } } function mtprotoHostPort(ins) { const key = `${MTPRO_INTERNAL_PORT}/tcp`; const bind = ins?.HostConfig?.PortBindings?.[key]; if (Array.isArray(bind) && bind[0]?.HostPort) return String(bind[0].HostPort); const ex = ins?.NetworkSettings?.Ports?.[key]; if (Array.isArray(ex) && ex[0]?.HostPort) return String(ex[0].HostPort); return null; } function mtprotoAdvertisedHost() { const a = process.env.MTPRO_PUBLIC_HOST?.trim() || process.env.CLIENT_CONFIG_ENDPOINT?.trim(); return a || ""; } /** Хост из заголовка запроса (без :порт) — фоллбек, когда env не заданы и пользователь открыл панель по IP/DNS. */ function hostFromRequest(req) { const raw = String(req?.headers?.host || "").trim(); if (!raw) return ""; const bare = raw.replace(/^\[/, "").replace(/\]:\d+$/, "]").replace(/:\d+$/, ""); if (!bare || /^(localhost|127\.|0\.0\.0\.0|::1?$)/i.test(bare)) return ""; return bare; } function mtprotoMaskedSecret(secret) { const s = String(secret || "").trim(); if (s.length < 10) return "········"; return `········${s.slice(-6)}`; } function mtprotoTelegramDeepLink(host, port, secretHex) { const h = String(host || "").trim(); const p = Number(port); const sec = String(secretHex || "").trim(); if (!h || !Number.isFinite(p) || !sec) return ""; try { return `tg://proxy?server=${encodeURIComponent(h)}&port=${encodeURIComponent(String(p))}&secret=${encodeURIComponent(sec)}`; } catch { return ""; } } function mtprotoNormalizeSecret(hex) { const s = String(hex || "").trim().toLowerCase(); if (/^[0-9a-f]{32}$/.test(s)) return s; return ""; } function mtprotoSnapshot(fallbackHost = "") { const ins = mtprotoParsedInspect(); const advEnv = mtprotoAdvertisedHost(); const advFallback = String(fallbackHost || "").trim(); const advEffective = advEnv || advFallback; const advSource = advEnv ? "env" : advFallback ? "request" : ""; if (!ins) { return { exists: false, running: false, container: MTPRO_CONTAINER, image: MTPRO_IMAGE, hostPort: null, advertisedHost: advEffective, advertisedHostSource: advSource, secretMasked: "", tgLink: "", hint: "Контейнер не найден — нажмите «Установить».", }; } const state = String(ins?.State?.Status || "").toLowerCase(); const running = state === "running"; const cfg = ins?.Config || {}; const envMap = envArrayToMap(cfg?.Env); const secret = envMap.SECRET || ""; const hostPort = mtprotoHostPort(ins); return { exists: true, running, container: MTPRO_CONTAINER, image: String(cfg?.Image || MTPRO_IMAGE), hostPort, advertisedHost: advEffective, advertisedHostSource: advSource, secretMasked: secret ? mtprotoMaskedSecret(secret) : "", tgLink: mtprotoTelegramDeepLink(advEffective, Number(hostPort || 0), secret), restartCount: Number(ins?.RestartCount || 0) || 0, hint: advEnv ? "" : advFallback ? `Хост ${advFallback} взят из адреса, по которому открыта эта панель. Чтобы зафиксировать публичный IP/DNS, задайте MTPRO_PUBLIC_HOST или CLIENT_CONFIG_ENDPOINT в контейнере панели.` : "Задайте MTPRO_PUBLIC_HOST или CLIENT_CONFIG_ENDPOINT на IP/DNS VPS — тогда появится прямая ссылка tg:// для клиентов.", }; } const app = express(); if (UI_HIDDEN.users || UI_HIDDEN.warp || UI_HIDDEN.cascade || UI_HIDDEN.mtproto) { console.warn( `UI_HIDDEN: users=${UI_HIDDEN.users} warp=${UI_HIDDEN.warp} cascade=${UI_HIDDEN.cascade} mtproto=${UI_HIDDEN.mtproto}`, ); } if (IS_COMMUNITY) { console.warn(`Редакция community (просмотр клиентов и удаление). Остальное — PRO: ${COMMUNITY_UPGRADE_URL}`); } if (ALLOW_COMMUNITY_GITHUB_ACTIVATION && IS_COMMUNITY) { console.warn( "Разрешена установка PRO из UI (ALLOW_COMMUNITY_GITHUB_ACTIVATION): GitHub-токен не сохранять в журналах; защитите доступ к паролю панели и Docker-сокету.", ); } app.use(express.json({ limit: "512kb" })); app.post("/api/community/run-private-install", requireAuth, async (req, res) => { if (!IS_COMMUNITY || !ALLOW_COMMUNITY_GITHUB_ACTIVATION) { return res.status(403).json({ error: "Запуск установки PRO из панели отключён.", }); } if (communityPrivateInstallBusy) { return res.status(429).json({ error: "Установка PRO уже выполняется. Через 1–2 минуты обновите страницу или см. журнал ниже.", }); } const token = typeof req.body?.githubToken === "string" ? req.body.githubToken.trim() : ""; if (!validateGithubBearerToken(token)) { return res.status(400).json({ error: "Нужен GitHub-токен с доступом к приватному репозиторию (classic: repo; fine-grained: Contents Read для репозитория со scripts/install.sh).", }); } let tmpDir = ""; communityPrivateInstallBusy = true; try { const ac = new AbortController(); const timeoutMs = Number(process.env.COMMUNITY_INSTALL_FETCH_MS || 60_000) || 60_000; const tmo = setTimeout(() => ac.abort(), timeoutMs); const ghRes = await fetch(COMMUNITY_PRIVATE_INSTALL_SCRIPT_URL, { redirect: "follow", headers: { Authorization: `token ${token}`, Accept: "*/*", "User-Agent": "amnezia-web-community-private-install", }, signal: ac.signal, }).finally(() => clearTimeout(tmo)); const buf = Buffer.from(await ghRes.arrayBuffer()); if (!ghRes.ok || buf.byteLength === 0) { communityPrivateInstallBusy = false; return res.status(400).json({ error: `Не удалось скачать install (${ghRes.status}). Проверьте токен и URL.`, urlForDebug: privateInstallScriptUrlLogged(), }); } if (buf.byteLength > PRIVATE_INSTALL_SCRIPT_MAX_BYTES) { communityPrivateInstallBusy = false; return res.status(400).json({ error: "Скачанный скрипт слишком большой — отказ." }); } const stageRoot = path.join(DATA_DIR, ".priv-install-tmp"); try { fs.mkdirSync(stageRoot, { recursive: true }); } catch { // } tmpDir = fs.mkdtempSync(path.join(stageRoot, "amnezia-priv-inst-")); const scriptPath = path.join(tmpDir, "install.sh"); fs.writeFileSync(scriptPath, buf); fs.chmodSync(scriptPath, 0o700); ensureDataDir(); const logAbs = path.join(DATA_DIR, "community-install-last.log"); const header = `\n${"=".repeat(60)}\n${new Date().toISOString()} — старт install из UI (COMMUNITY)\nURL: ${COMMUNITY_PRIVATE_INSTALL_SCRIPT_URL}\n${"=".repeat(60)}\n`; fs.appendFileSync(logAbs, header); const useDockerCliHelper = !COMMUNITY_DISABLE_DOCKER_CLI_HELPER; const helperImage = process.env.COMMUNITY_PRO_INSTALL_HELPER_IMAGE?.trim() || "docker:26-cli"; const freePanelName = shellSafeDockerContainerName( process.env.FREE_PANEL_CONTAINER_FOR_PRO_INSTALL, "amnezia-admin", ); const freeLandingName = shellSafeDockerContainerName( process.env.FREE_LANDING_CONTAINER_FOR_PRO_INSTALL, "amnezia-web-landing", ); const staleProName = shellSafeDockerContainerName( process.env.STALE_PRO_PANEL_CONTAINER_FOR_PRO_INSTALL, "amnezia-admin-pro", ); if (useDockerCliHelper) { fs.appendFileSync( logAbs, `\n→ Одноразовый установщик: ${helperImage}; FREE=${freePanelName}, лендинг=${freeLandingName}, удаление зависшего PRO=${staleProName}. Отключить слой: COMMUNITY_DISABLE_DOCKER_CLI_HELPER=1\n`, ); } else { fs.appendFileSync( logAbs, "\n→ COMMUNITY_DISABLE_DOCKER_CLI_HELPER=1 — install идёт в том же контейнере; FREE не удаляется до скрипта (частый конфликт :8080 с PRO).\n", ); } const logStream = useDockerCliHelper ? null : fs.createWriteStream(logAbs, { flags: "a" }); let finalized = false; const finalize = (code = null, signal = null, errText = "") => { if (finalized) return; finalized = true; communityPrivateInstallBusy = false; const foot = `\n--- завершено ${new Date().toISOString()}, code=${code ?? "?"}${ signal ? ` signal=${signal}` : "" }${errText ? ` err=${errText}` : ""} ---\n`; try { fs.appendFileSync(logAbs, foot); } catch { // } const rmTmp = () => { try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { // } }; if (logStream) { logStream.end(() => rmTmp()); } else { rmTmp(); } }; if (useDockerCliHelper) { const dataHost = HOST_DATA_DIR; const relStage = path.relative(DATA_DIR, tmpDir); const stageInHelper = `/mnt/data/${relStage}`; const autoAwgProfilesJson = autoDiscoverAwgProfilesJson(); if (autoAwgProfilesJson) { fs.appendFileSync( logAbs, `→ AWG_PROFILES для PRO собран автоматически из живых amnezia-awg* контейнеров: ${autoAwgProfilesJson}\n`, ); } const prelude = COMMUNITY_SKIP_REMOVE_FREE_BEFORE_PRIVATE_PRO ? "sleep 2" : [ "sleep 3", `docker rm -f ${freePanelName} ${freeLandingName} 2>/dev/null || true`, `docker rm -f ${staleProName} 2>/dev/null || true`, 'for __amz_hp in $(docker ps -aq --filter name=amnezia-admin-pro 2>/dev/null); do docker rm -f "$__amz_hp" 2>/dev/null || true; done', ].join("; "); const fetchTools = dockerCliHelperEnsureFetchToolsScript(); const sh = ` exec >>"/mnt/data/community-install-last.log" 2>&1 set -eu echo "→ helper (docker cli): prelude + bash/curl..." echo " staging: ${stageInHelper} (host: ${tmpDir.replace(DATA_DIR, dataHost)})" [ -f "${stageInHelper}/install.sh" ] || { echo "⚠ helper: нет install.sh в ${stageInHelper} (проверьте монтирование ${dataHost}:/mnt/data)"; ls -la "${stageInHelper}" 2>&1 || true; exit 125; } ${prelude} ${fetchTools ? `${fetchTools}\n` : ""}command -v bash >/dev/null 2>&1 || { echo "⚠ helper: нет bash после apk/apt — см. ошибки apk выше"; exit 126; } command -v curl >/dev/null 2>&1 || { echo "⚠ helper: нет curl после apk/apt"; exit 126; } command -v openssl >/dev/null 2>&1 || { echo "⚠ helper: нет openssl после apk/apt — install.sh падает на генерации пароля"; exit 126; } command -v tar >/dev/null 2>&1 || { echo "⚠ helper: нет tar после apk/apt — install.sh падает на распаковке релиза"; exit 126; } set +e ( cd ${stageInHelper} && bash ./install.sh ) rv=$? set -e echo "" echo "--- helper завершён code=\${rv} (\$(date -u +%Y-%m-%dT%H:%M:%SZ) UTC) ---" exit "\${rv}" `.trim(); const dr = spawn( "docker", [ "run", "--rm", "--pull=missing", "-v", "/var/run/docker.sock:/var/run/docker.sock", `-v`, `${dataHost}:/mnt/data`, "-e", "GITHUB_TOKEN", "-e", "GH_TOKEN", "-e", "GIT_TERMINAL_PROMPT", ...(autoAwgProfilesJson ? ["-e", "AWG_PROFILES"] : []), helperImage, "sh", "-lc", sh, ], { detached: true, stdio: "ignore", env: { ...process.env, GITHUB_TOKEN: token, GH_TOKEN: token, GIT_TERMINAL_PROMPT: "0", ...(autoAwgProfilesJson ? { AWG_PROFILES: autoAwgProfilesJson } : {}), }, }, ); dr.unref(); dr.once("exit", (code, signal) => finalize(code, signal)); dr.once("error", (err) => { console.warn("community-private-install docker-cli helper:", err?.message || err); finalize(null, null, String(err.message || err)); }); } else { const child = spawn("bash", [scriptPath], { cwd: tmpDir, env: { ...process.env, GITHUB_TOKEN: token, GH_TOKEN: token, GIT_TERMINAL_PROMPT: "0", }, stdio: ["ignore", "pipe", "pipe"], }); child.stdout.pipe(logStream, { end: false }); child.stderr.pipe(logStream, { end: false }); child.once("exit", (code, signal) => finalize(code, signal)); child.once("error", (err) => { console.warn("community-private-install bash:", err?.message || err); finalize(null, null, String(err.message || err)); }); } res.status(202).json({ ok: true, queued: true, message: useDockerCliHelper ? "Установка PRO: отдельный контейнер сначала снимает FREE (освобождается порт панели) и зависший PRO при наличии, затем выполняется install.sh. Подождите 3–10 мин и откройте адрес снова. Журнал: на VPS каталог данных панели (часто /opt/amnezia-admin-data/community-install-last.log)." : "Режим без docker-helper: при «port already allocated» снимите FREE вручную. Журнал: /data/community-install-last.log.", logInsideContainer: "community-install-last.log", }); } catch (e) { communityPrivateInstallBusy = false; if (tmpDir) { try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { // } } const msg = e?.name === "AbortError" ? "Таймаут загрузки install.sh с GitHub." : e?.message || String(e); return res.status(400).json({ error: msg }); } }); const COMMUNITY_INSTALL_LOG_BASENAME = "community-install-last.log"; function clampCommunityInstallLogRange(name, fallback, minV, maxV) { const x = Number(process.env[name]); if (!Number.isFinite(x)) return fallback; return Math.min(Math.max(Math.trunc(x), minV), maxV); } /** * Прочитать кусок `community-install-last.log`: без `since` — хвост (для первого экрана); * с `since` — блок байт начиная с offset (до CHUNK байт за запрос). */ app.get("/api/community/install-log", requireAuth, (req, res) => { if (!IS_COMMUNITY || !ALLOW_COMMUNITY_GITHUB_ACTIVATION) { return res.status(403).json({ error: "Недоступно." }); } const TAIL_BYTES = clampCommunityInstallLogRange( "COMMUNITY_INSTALL_LOG_TAIL_BYTES", 96 * 1024, 1024, 512 * 1024, ); const MAX_CHUNK = clampCommunityInstallLogRange( "COMMUNITY_INSTALL_LOG_API_CHUNK_MAX", 512 * 1024, 4096, 2 * 1024 * 1024, ); const logPath = path.join(DATA_DIR, COMMUNITY_INSTALL_LOG_BASENAME); const sendAbsent = () => res.json({ exists: false, totalBytes: 0, chunk: "", since0: 0, since1: 0, resetSuggested: false, }); if (!fs.existsSync(logPath)) { sendAbsent(); return; } let stat; try { stat = fs.statSync(logPath); if (!stat.isFile()) { sendAbsent(); return; } } catch { return res.status(500).json({ error: "Не удалось прочитать журнал установки." }); } const totalBytes = Number(stat.size) || 0; let sinceParsed = NaN; const q = req.query.since; if (typeof q === "string" && q.trim() !== "") { sinceParsed = Number(q.trim()); } let resetSuggested = false; let sinceByte; if (!Number.isFinite(sinceParsed) || sinceParsed < 0) { sinceByte = Math.max(0, totalBytes - TAIL_BYTES); } else if (sinceParsed > totalBytes) { resetSuggested = true; sinceByte = Math.max(0, totalBytes - TAIL_BYTES); } else { sinceByte = sinceParsed; } const readable = Math.max(0, totalBytes - sinceByte); const readLen = Math.min(readable, MAX_CHUNK); if (readLen === 0 || totalBytes <= 0) { res.json({ exists: true, totalBytes, chunk: "", since0: sinceByte, since1: sinceByte, resetSuggested, }); return; } let chunkBuf = Buffer.alloc(readLen); try { const fd = fs.openSync(logPath, "r"); try { fs.readSync(fd, chunkBuf, 0, readLen, sinceByte); } finally { fs.closeSync(fd); } } catch { return res.status(500).json({ error: "Ошибка чтения журнала установки." }); } res.json({ exists: true, totalBytes, chunk: chunkBuf.toString("utf8"), since0: sinceByte, since1: sinceByte + readLen, resetSuggested, }); }); app.get("/health", (_req, res) => { res.json({ ok: true, version: PANEL_VERSION }); }); /** Публичные ссылки доната / Telegram‑спонсора для интерфейса панели (без авторизации). */ app.get("/api/panel/footer", (_req, res) => { res.json(panelPromoFooterPayload()); }); app.get("/api/session", (req, res) => { if (!readSession(getSessionToken(req))) { res.status(401).json({ ok: false }); return; } res.json({ ok: true }); }); app.get("/api/server-time", requireAuth, (req, res) => { const now = new Date(); const timeZone = resolveServerClockTimeZone(); let formatted; try { formatted = now.toLocaleString("ru-RU", { dateStyle: "medium", timeStyle: "medium", timeZone, }); } catch { formatted = now.toLocaleString("ru-RU", { dateStyle: "medium", timeStyle: "medium", }); } const browserTz = typeof req.query.browserTz === "string" ? req.query.browserTz.trim() : ""; const zoneCompare = buildZoneCompare(timeZone, browserTz, now); res.json({ iso: now.toISOString(), formatted, timeZone, browserTimeZone: browserTz || null, zoneSame: zoneCompare.sameZone, zoneCompareHint: zoneCompare.hint, zoneDiffMinutes: zoneCompare.diffMinutes ?? null, }); }); app.get("/api/time-sync-capabilities", requireAuth, (_req, res) => { if (IS_COMMUNITY) { res.json({ hostTimeSync: false, sshHost: process.env.TIME_SYNC_SSH_HOST?.trim() || "172.17.0.1", serverClockTimeZone: resolveServerClockTimeZone(), communityBlocked: true, }); return; } res.json({ hostTimeSync: hostTimeSyncConfigured(), sshHost: process.env.TIME_SYNC_SSH_HOST?.trim() || "172.17.0.1", serverClockTimeZone: resolveServerClockTimeZone(), }); }); app.get("/api/mtproto/status", requireAuth, (req, res) => { if (effectiveUiHidden().mtproto) { return res.status(403).json({ error: MSG_UI_MTProto_OFF }); } const snap = mtprotoSnapshot(hostFromRequest(req)); let logsTail = ""; if (snap.exists && snap.running) { const l = dockerSpawnSync(["logs", "--tail", "100", MTPRO_CONTAINER], 12_000); if (l.code === 0) logsTail = l.stdout.trim().slice(-4500); } res.json({ ...snap, logsTail }); }); app.post("/api/mtproto/install", requireAuth, (req, res) => { if (effectiveUiHidden().mtproto) { return res.status(403).json({ error: MSG_UI_MTProto_OFF }); } if (!/^[a-zA-Z][a-zA-Z0-9_.-]*$/.test(MTPRO_CONTAINER)) { return res.status(500).json({ error: "Некорректное имя Docker-контейнера MTProto (переменная окружения MTPRO_PROXY_CONTAINER).", }); } if (!/^[a-zA-Z0-9_.\-/:@]+$/.test(MTPRO_IMAGE)) { return res.status(500).json({ error: "Некорректное имя образа MTProto (переменная MTPRO_PROXY_IMAGE).", }); } if (!/^[0-9.:a-fA-F]+$/.test(MTPRO_PUBLISH_BIND)) { return res.status(500).json({ error: "Некорректный MTPRO_PUBLISH_BIND.", }); } let hostPort = MTPRO_PUBLISH_PORT_DEFAULT; const hpReq = req.body?.hostPort; if (hpReq !== undefined && hpReq !== null && hpReq !== "") { const parsed = typeof hpReq === "number" ? hpReq : Number.parseInt(String(hpReq).trim(), 10); if (!Number.isFinite(parsed) || parsed < 512 || parsed > 65535) { return res.status(400).json({ error: "Порт (hostPort): целое 512 … 65535." }); } hostPort = parsed; } const userSec = mtprotoNormalizeSecret(typeof req.body?.secret === "string" ? req.body.secret : ""); const secretFinal = userSec || crypto.randomBytes(16).toString("hex"); if (mtprotoInstallBusy) return res.status(429).json({ error: "Установка MTProto уже выполняется." }); mtprotoInstallBusy = true; try { const rm = dockerSpawnSync(["rm", "-f", MTPRO_CONTAINER], 120_000); if (rm.code !== 0 && !/No such container/i.test(`${rm.stderr} ${rm.stdout}`)) { return res.status(400).json({ error: `docker rm завершился с кодом ${rm.code}`, stderr: rm.stderr.trim().slice(0, 2000), }); } const pull = dockerSpawnSync(["pull", MTPRO_IMAGE], 600_000); if (pull.code !== 0) { return res.status(400).json({ error: "Не удалось docker pull образа.", stderr: `${pull.stderr || ""}${pull.stdout || ""}`.trim().slice(0, 2000), }); } const pub = `${MTPRO_PUBLISH_BIND}:${hostPort}:${MTPRO_INTERNAL_PORT}/tcp`; const args = [ "run", "-d", "--name", MTPRO_CONTAINER, "--restart", "unless-stopped", "-p", pub, "-e", `SECRET=${secretFinal}`, MTPRO_IMAGE, ]; const run = dockerSpawnSync(args, 120_000); if (run.code !== 0) { return res.status(400).json({ error: `docker run завершился с кодом ${run.code}`, stderr: `${run.stderr}${run.stdout}`.trim().slice(0, 4000), }); } const fallback = hostFromRequest(req); const advEnv = mtprotoAdvertisedHost(); const advEffective = advEnv || fallback; const snap = mtprotoSnapshot(fallback); const tgLink = mtprotoTelegramDeepLink(advEffective, hostPort, secretFinal); res.json({ ok: true, secretHex: secretFinal, hostPort, tgLink, advertisedHost: advEffective, advertisedHostSource: advEnv ? "env" : fallback ? "request" : "", snapshot: snap, }); } finally { mtprotoInstallBusy = false; } }); app.post("/api/mtproto/remove", requireAuth, (_req, res) => { if (effectiveUiHidden().mtproto) { return res.status(403).json({ error: MSG_UI_MTProto_OFF }); } const r = dockerSpawnSync(["rm", "-f", MTPRO_CONTAINER], 120_000); if (r.code !== 0 && !/No such container/i.test(`${r.stderr} ${r.stdout}`)) { return res.status(400).json({ error: "Не удалось удалить контейнер MTProto.", stderr: `${r.stderr}${r.stdout}`.trim().slice(0, 2000), }); } res.json({ ok: true }); }); app.post("/api/mtproto/restart", requireAuth, (_req, res) => { if (effectiveUiHidden().mtproto) { return res.status(403).json({ error: MSG_UI_MTProto_OFF }); } const snap = mtprotoSnapshot(); if (!snap.exists) return res.status(400).json({ error: "Контейнер MTProto ещё не установлен." }); const r = dockerSpawnSync(["restart", MTPRO_CONTAINER], 180_000); if (r.code !== 0) { return res.status(400).json({ error: `docker restart завершился с кодом ${r.code}`, stderr: `${r.stderr}${r.stdout}`.trim().slice(0, 2000), }); } res.json({ ok: true }); }); app.post("/api/sync-host-time", requireAuth, requireProTier, async (req, res) => { if (!hostTimeSyncConfigured()) { return res.status(503).json({ error: "Синхронизация времени хоста недоступна (нет sshpass или TIME_SYNC_DISABLED=1).", }); } const pw = req.body?.rootPassword; const unixMsRaw = req.body?.unixMs; const unixMs = typeof unixMsRaw === "number" && Number.isFinite(unixMsRaw) ? unixMsRaw : Date.now(); if (typeof pw !== "string" || !pw) { return res.status(400).json({ error: "Укажите пароль root VPS" }); } const unixSec = Math.floor(unixMs / 1000); if (!Number.isFinite(unixSec)) { return res.status(400).json({ error: "Некорректное время" }); } const host = process.env.TIME_SYNC_SSH_HOST?.trim() || "172.17.0.1"; const remoteCmd = `bash -lc 'date -u --set=@${unixSec} 2>/dev/null || date -s @${unixSec}; (command -v hwclock >/dev/null && hwclock -w --utc) || true; date -u +%Y-%m-%dT%H:%M:%SZ'`; try { const confirmed = await sshRootRun(pw, host, remoteCmd); res.json({ ok: true, utc: confirmed }); } catch { console.warn("sync-host-time: ssh не выполнен"); res.status(400).json({ error: "Не удалось выставить время по SSH. Проверьте пароль root, вход root по паролю на хосте и переменную TIME_SYNC_SSH_HOST (часто 172.17.0.1 с контейнера).", }); } }); app.post("/api/warp/host-setup", requireAuth, requireProTier, async (req, res) => { if (UI_HIDDEN.warp) { return res.status(403).json({ error: MSG_UI_WARP_OFF }); } if (!hostTimeSyncConfigured()) { return res.status(503).json({ error: "С панели недоступно: в образе панели нет sshpass или задано TIME_SYNC_DISABLED=1. Запустите на хосте VPS вручную: bash /opt/amnezia-admin/scripts/warp-amnezia.sh install", }); } const pw = req.body?.rootPassword; const cmd = req.body?.cmd; if (typeof pw !== "string" || !pw.trim()) { return res.status(400).json({ error: "Укажите пароль root VPS" }); } if (cmd !== "install" && cmd !== "uninstall") { return res.status(400).json({ error: "Ожидается cmd: install или uninstall" }); } const rt = runtimeForRequest(req); const container = String(rt.profile.container || "").trim(); if (!/^[a-zA-Z0-9_.-]+$/.test(container)) { return res.status(400).json({ error: "Некорректное имя контейнера в профиле AWG" }); } let installDir = "/opt/amnezia-admin"; try { installDir = assertSafeUnixPath(process.env.WARP_SSH_INSTALL_DIR?.trim() || "/opt/amnezia-admin"); } catch { return res.status(500).json({ error: "Некорректная переменная WARP_SSH_INSTALL_DIR на сервере панели" }); } const host = process.env.TIME_SYNC_SSH_HOST?.trim() || "172.17.0.1"; const remoteCmd = `bash -lc 'cd ${installDir} && chmod +x scripts/warp-amnezia.sh 2>/dev/null || true && AWG_CONTAINER=${container} ./scripts/warp-amnezia.sh ${cmd}'`; try { const out = await sshRootRun(pw.trim(), host, remoteCmd); res.json({ ok: true, output: out.slice(0, 8000) }); } catch (e) { console.warn("warp host-setup:", e); res.status(400).json({ error: String(e.message || e) || "Не удалось выполнить по SSH. Проверьте пароль root, что вход root по паролю разрешён, переменную TIME_SYNC_SSH_HOST и наличие каталога со скриптом на хосте.", }); } }); app.post("/api/login", (req, res) => { const pw = req.body?.password; if (typeof pw !== "string" || !pw) { res.status(400).json({ error: "password required" }); return; } if (!verifyPassword(pw, passwordHashStored)) { res.status(401).json({ error: "Неверный пароль" }); return; } const token = signSession({ exp: Date.now() + SESSION_MS }); setSessionCookie(res, token, Math.floor(SESSION_MS / 1000)); res.json({ ok: true }); }); app.post("/api/logout", (_req, res) => { clearSessionCookie(res); res.json({ ok: true }); }); app.post("/api/change-password", requireAuth, (req, res) => { const cur = req.body?.currentPassword; const neu = req.body?.newPassword; if (typeof cur !== "string" || typeof neu !== "string") { res.status(400).json({ error: "currentPassword и newPassword обязательны" }); return; } if (neu.length < 8) { res.status(400).json({ error: "Новый пароль — не короче 8 символов" }); return; } if (!verifyPassword(cur, passwordHashStored)) { res.status(401).json({ error: "Текущий пароль неверный" }); return; } passwordHashStored = hashPassword(neu); fs.writeFileSync(PW_FILE, `${passwordHashStored}\n`, { mode: 0o600 }); rotateSessionSecret(); clearSessionCookie(res); res.json({ ok: true, message: "Пароль изменён. Войдите снова." }); }); app.get("/api/protocols", requireAuth, (req, res) => { const rt = runtimeForRequest(req); const hintSingle = PROFILES.length < 2 ? IS_COMMUNITY ? "Один инстанс в интерфейсе. Несколько контейнеров и профиль AWG_PROFILES — в полной панели PRO." : "Сейчас один инстанс: при установке не передали AWG_PROFILES или не восстановился снимок. Задайте JSON профилей и запустите install.sh — он сохранится в /root/amnezia-admin.awg-profiles.json." : ""; res.json({ currentId: rt.profile.id, currentLabel: rt.profile.label, profiles: PROFILES.map((p) => ({ id: p.id, label: p.label, container: p.container, })), singleProfile: PROFILES.length < 2, profilesPersistHint: hintSingle, edition: editionPayload(), uiHidden: { ...effectiveUiHidden() }, }); }); 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 rt.dockerExec(`${rt.profile.wgBinary} show ${rt.tunnelIface}`); } catch { wgShow = ""; } const warpMeta = await warpSummaryForRt(rt); const warpSelected = new Set( warpMeta.supported && warpMeta.installed ? warpMeta.selectedAllowedIps : [], ); const { conf, clients, peerByKey } = await rt.loadState(); const rows = clients.map((c) => { const id = c.clientId; const peer = peerByKey.get(id); const ud = c.userData || {}; const activeInConf = !!peer; return { clientId: id, name: ud.clientName || `${id.slice(0, 10)}…`, allowedIps: peer?.allowedIPs || ud.allowedIps || ud.preservedAllowedIPs || null, activeInConf, 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, dataSent: ud.dataSent || null, warpEnabled: Boolean(warpMeta.supported && warpMeta.installed) && activeInConf && peerUsesWarp(peer, warpSelected), exportAvailable: clientHasExportableLastConfig(c), }; }); const warpOut = warpMeta.supported === false ? { supported: false } : { supported: true, installed: warpMeta.installed, running: warpMeta.running, exitIp: warpMeta.exitIp, wgShowWarp: warpMeta.wgShowWarp || "", selectedAllowedIps: warpMeta.selectedAllowedIps, paths: warpMeta.paths, hostSshInstall: hostTimeSyncConfigured(), sshHost: process.env.TIME_SYNC_SSH_HOST?.trim() || "172.17.0.1", installDir: process.env.WARP_SSH_INSTALL_DIR?.trim() || "/opt/amnezia-admin", }; res.json({ profileId: rt.profile.id, profileLabel: rt.profile.label, container: rt.profile.container, protocol: "AmneziaWG", peerCount: conf.peers.length, clients: rows, wgShow, warp: warpOut, uiHidden: { ...effectiveUiHidden() }, edition: editionPayload(), }); } catch (e) { console.error(e); res.status(500).json({ error: String(e.message || e) }); } }); async function serveClientConfigExport(req, res) { if (IS_COMMUNITY) { res.status(403).json({ error: "Экспорт .conf доступен в версии PRO.", upgradeRequired: true, upgradeUrl: COMMUNITY_UPGRADE_URL, }); return; } const tokenOk = req.method === "GET" && verifyExportQueryToken(typeof req.query.token === "string" ? req.query.token : ""); let rt; if (tokenOk) { if (PROFILES.length > 1) { const pid = typeof req.query.profileId === "string" ? req.query.profileId.trim() : ""; const p = PROFILES.find((x) => x.id === pid); if (!p) { res.status(400).json({ error: "При нескольких инстансах укажите в URL параметр profileId (как в списке «Инстанс» в панели).", }); return; } rt = createRuntime(p); } else { rt = createRuntime(PROFILES[0]); } } else { rt = runtimeFromExportRequest(req); } const rawId = req.method === "POST" ? req.body?.clientId : req.query.clientId ?? req.query.id; const clientId = typeof rawId === "string" ? decodeURIComponent(rawId.trim()) : ""; if (!clientId) { res.status(400).json({ error: "Укажите clientId (в теле POST или query GET)" }); return; } try { const { conf, clients } = await rt.loadState(); const row = clients.find((c) => c.clientId === clientId); if (!row) { res.status(404).json({ error: "Клиент не найден в clientsTable" }); return; } const lc = parseLastConfigFromClientRow(row); if (!lc) { res.status(404).json({ error: "На сервере нет userData.last_config для этого клиента. Полный конфиг хранится в приложении Amnezia на устройстве, где ключ создавали (или синхронизируйте клиентов с сервером из приложения).", }); return; } const ifaceMap = parseInterfaceKeyValues(conf.head); let text; try { text = await buildClientConfExport(rt, lc, ifaceMap, req, row, conf); } catch (e) { res.status(400).json({ error: String(e.message || e) }); return; } const ud = row.userData || {}; const baseName = safeExportFilenamePart(ud.clientName, clientId.slice(0, 12)); res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.setHeader("Content-Disposition", `attachment; filename="amnezia-${baseName}.conf"`); res.send(text); } catch (e) { console.error(e); res.status(500).json({ error: String(e.message || e) }); } } app.get("/api/clients/export-config", requireAuthOrExportToken, (req, res) => { void serveClientConfigExport(req, res); }); app.post("/api/clients/export-config", requireAuth, (req, res) => { void serveClientConfigExport(req, res); }); /** * Новый клиент для каскада: генерирует ключи, добавляет peer на сервер, сохраняет last_config, * отдаёт .conf с Endpoint = endpointHost:endpointPort (ваш промежуточный узел). */ app.post("/api/clients/create-cascade", requireAuth, requireProTier, async (req, res) => { if (UI_HIDDEN.cascade) { return res.status(403).json({ error: MSG_UI_CASCADE_OFF }); } const rt = runtimeFromExportRequest(req); let endpointHost; try { endpointHost = assertCascadeEndpointHost(req.body?.endpointHost); } catch (e) { res.status(400).json({ error: String(e.message || e) }); return; } let endpointPort; const rawPort = req.body?.endpointPort; if (rawPort != null && rawPort !== "") { endpointPort = Number(rawPort); if (!Number.isFinite(endpointPort) || endpointPort < 1 || endpointPort > 65535) { res.status(400).json({ error: "Некорректный порт Endpoint (1–65535)." }); return; } } try { await rt.backupRemoteFiles(); const { conf, clients } = await rt.loadState(); const ifaceMap = parseInterfaceKeyValues(conf.head); if (!ifaceMap.PrivateKey) { res.status(400).json({ error: "В wg/awg конфиге сервера нет PrivateKey в [Interface]." }); return; } const tunnelIp = normalizeCascadeTunnelIp(conf, ifaceMap, req.body?.tunnelIp); const listenPort = ifaceMap.ListenPort ? Number(ifaceMap.ListenPort) : NaN; if (endpointPort == null) { endpointPort = Number.isFinite(listenPort) && listenPort > 0 ? listenPort : rt.profile.wgBinary === "awg" ? 55424 : 51820; } const psk = await rt.inferPskFromConf(conf); if (!psk || typeof psk !== "string") { res.status(400).json({ error: "Не удалось определить PresharedKey (нет peer или файла psk)." }); return; } const serverPub = await wgPubkeyFromPrivate(rt, ifaceMap.PrivateKey); const { priv, pub } = await awgGenKeypair(rt); if (clients.some((c) => c.clientId === pub)) { res.status(409).json({ error: "Коллизия ключей — попробуйте ещё раз." }); return; } const obf = obfuscationFieldsFromServerHead(ifaceMap); const lc = { client_priv_key: priv, server_pub_key: serverPub, psk_key: psk, client_ip: tunnelIp, hostName: endpointHost, port: endpointPort, allowed_ips: ["0.0.0.0/0", "::/0"], ...obf, }; const peerRaw = `[Peer] PublicKey = ${pub} PresharedKey = ${psk} AllowedIPs = ${tunnelIp}/32 `; const peer = parsePeerBlock(`${peerRaw}\n`); const nextPeers = [...conf.peers, peer]; const nextConfText = serializeAwgConf(conf.head, nextPeers); const rawName = req.body?.clientName; const clientName = typeof rawName === "string" && rawName.trim() ? rawName.trim().replace(/\s+/g, " ").slice(0, 200) : `Каскад ${tunnelIp}`; const last_config = JSON.stringify(lc); const newRow = { clientId: pub, userData: { clientName, creationDate: new Date().toISOString(), last_config, allowedIps: `${tunnelIp}/32`, }, }; const nextClients = [...clients, newRow]; await rt.dockerWriteFile(rt.confPath, nextConfText); await rt.dockerWriteFile(rt.clientsPath, stringifyClientsTable(nextClients)); await rt.applySyncconf(); const confAfter = { ...conf, peers: nextPeers }; let text; try { text = await buildClientConfExport(rt, lc, ifaceMap, req, newRow, confAfter); } catch (e) { res.status(500).json({ error: String(e.message || e) }); return; } const baseName = safeExportFilenamePart(clientName, pub.slice(0, 12)); res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.setHeader("Content-Disposition", `attachment; filename="amnezia-cascade-${baseName}.conf"`); res.send(text); } catch (e) { console.error(e); res.status(500).json({ error: String(e.message || e) }); } }); app.post("/api/warp/start", requireAuth, requireProTier, async (req, res) => { if (UI_HIDDEN.warp) { return res.status(403).json({ error: MSG_UI_WARP_OFF }); } const rt = runtimeForRequest(req); if (!(await warpFileExists(rt, rt.profile.warpConf))) { return res.status(400).json({ error: "WARP не установлен (нет warp.conf). На хосте: scripts/warp-amnezia.sh install — или игнорируйте раздел, если WARP не нужен (см. README).", }); } try { await rt.dockerExec(`wg-quick down '${rt.profile.warpConf}' 2>/dev/null || true`); await rt.dockerExec(`wg-quick up '${rt.profile.warpConf}'`); res.json({ ok: true }); } catch (e) { console.error(e); res.status(500).json({ error: String(e.message || e) }); } }); app.post("/api/warp/stop", requireAuth, requireProTier, async (req, res) => { if (UI_HIDDEN.warp) { return res.status(403).json({ error: MSG_UI_WARP_OFF }); } const rt = runtimeForRequest(req); if (!(await warpFileExists(rt, rt.profile.warpConf))) { return res.status(400).json({ error: "WARP не установлен." }); } try { await rt.dockerExec(`wg-quick down '${rt.profile.warpConf}' 2>/dev/null || true`); res.json({ ok: true }); } catch (e) { console.error(e); res.status(500).json({ error: String(e.message || e) }); } }); app.post("/api/warp/routing", requireAuth, requireProTier, async (req, res) => { if (UI_HIDDEN.warp) { return res.status(403).json({ error: MSG_UI_WARP_OFF }); } const rt = runtimeForRequest(req); if (!(await warpFileExists(rt, rt.profile.warpConf))) { return res.status(400).json({ error: "WARP не установлен. Сначала scripts/warp-amnezia.sh install на хосте VPS (root), либо не используйте этот раздел.", }); } const raw = req.body?.selectedAllowedIps; if (!Array.isArray(raw)) { return res.status(400).json({ error: "Ожидается selectedAllowedIps: массив адресов вида 10.8.1.2/32" }); } let selected; try { selected = raw.map((x) => assertAllowedIpCidr(String(x).trim())); } catch (e) { return res.status(400).json({ error: String(e.message || e) }); } try { const { conf } = await rt.loadState(); const allowed = activePeerAllowedIpSet(conf); for (const ip of selected) { if (!allowed.has(ip)) { return res.status(400).json({ error: `Адрес ${ip} не совпадает ни с одним активным peer (AllowedIPs) в текущем инстансе.`, }); } } await warpPersistAndRestart(rt, selected); res.json({ ok: true }); } catch (e) { console.error(e); res.status(500).json({ error: String(e.message || e) }); } }); app.post("/api/clients/disable", requireAuth, requireProTier, async (req, res) => { const rt = runtimeForRequest(req); const clientId = req.body?.clientId; if (!clientId) return res.status(400).json({ error: "clientId required" }); let ts; try { ts = normalizeDisconnectedAtOptional(req.body?.disconnectedAt); } catch (e) { return res.status(400).json({ error: String(e.message || e) }); } try { 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: msg }); } }); app.post("/api/clients/enable", requireAuth, requireProTier, async (req, res) => { const rt = runtimeForRequest(req); const clientId = req.body?.clientId; if (!clientId) return res.status(400).json({ error: "clientId required" }); try { 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" }); } 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 || {}) }; const psk = ud.preservedPresharedKey || conf.peers[0]?.presharedKey || (await rt.inferPskFromConf(conf)); const ips = ud.preservedAllowedIPs || ud.allowedIps; if (!psk || !ips) { return res.status(400).json({ error: "Missing preserved keys — cannot enable (restore from backup or re-import in Amnezia)", }); } const raw = `[Peer] PublicKey = ${clientId} PresharedKey = ${psk} AllowedIPs = ${ips}`; const peer = parsePeerBlock(`${raw}\n`); const nextPeers = [...conf.peers, peer]; 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 rt.dockerWriteFile(rt.confPath, nextConfText); await rt.dockerWriteFile(rt.clientsPath, stringifyClientsTable(clients)); await rt.applySyncconf(); res.json({ ok: true }); } catch (e) { console.error(e); res.status(500).json({ error: String(e.message || e) }); } }); app.post("/api/clients/disconnect-date", requireAuth, requireProTier, async (req, res) => { const rt = runtimeForRequest(req); const clientId = req.body?.clientId; if (!clientId) return res.status(400).json({ error: "clientId required" }); let iso; try { iso = requireDisconnectedAt(req.body?.disconnectedAt); } catch (e) { return res.status(400).json({ error: String(e.message || e) }); } const scheduleTunnelDisconnect = Boolean(req.body?.scheduleTunnelDisconnect); try { 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 || {}) }; 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 rt.dockerWriteFile(rt.clientsPath, stringifyClientsTable(clients)); res.json({ ok: true }); } catch (e) { console.error(e); res.status(500).json({ error: String(e.message || e) }); } }); app.post("/api/clients/rename", requireAuth, requireProTier, 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" }); if (typeof rawName !== "string") { return res.status(400).json({ error: "name required" }); } const name = rawName.trim().replace(/\s+/g, " "); if (!name) return res.status(400).json({ error: "Имя не может быть пустым" }); if (name.length > 200) { return res.status(400).json({ error: "Имя не длиннее 200 символов" }); } try { 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 rt.dockerWriteFile(rt.clientsPath, stringifyClientsTable(clients)); res.json({ ok: true }); } catch (e) { console.error(e); res.status(500).json({ error: String(e.message || e) }); } }); 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 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 rt.dockerWriteFile(rt.confPath, nextConfText); await rt.dockerWriteFile(rt.clientsPath, stringifyClientsTable(nextClients)); await rt.applySyncconf(); res.json({ ok: true }); } catch (e) { console.error(e); res.status(500).json({ error: String(e.message || e) }); } }); const pub = path.join(__dirname, "public"); if (fs.existsSync(pub)) { app.use( express.static(pub, { setHeaders(res, filePath) { const lower = filePath.toLowerCase(); if (lower.endsWith(".html") || lower.endsWith(".js") || lower.endsWith(".css")) { res.setHeader("Cache-Control", "no-store"); } }, }), ); } app.use((_req, res) => { res.status(404).send("Not found"); }); app.listen(PORT, "0.0.0.0", () => { const summary = PROFILES.map((p) => `${p.label}→${p.container}`).join("; "); console.log(`amnezia-admin v${PANEL_VERSION} on :${PORT} · ${summary} · data:${DATA_DIR}`); }); setInterval(() => { if (!IS_COMMUNITY) { processAllScheduledDisconnects().catch((e) => console.error("scheduleDisconnect:", e)); } }, SCHEDULER_MS); setTimeout(() => { if (!IS_COMMUNITY) { processAllScheduledDisconnects().catch((e) => console.error("scheduleDisconnect:", e)); } }, 4000);