import express from "express"; import { spawn, spawnSync } 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 — для /health и сверки образа после деплоя. */ let PANEL_VERSION = "0.0.0"; try { const pkgRaw = fs.readFileSync(path.join(__dirname, "package.json"), "utf8"); const pv = JSON.parse(pkgRaw)?.version; if (typeof pv === "string" && pv.trim()) PANEL_VERSION = pv.trim(); } catch { /* noop */ } 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,mtproto` или `UI_HIDE_*` по отдельности. */ 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 COMMUNITY_UPGRADE_URL = process.env.COMMUNITY_UPGRADE_URL?.trim() || "https://github.com/andrey271192/amnezia_web-PRO"; const COMMUNITY_UPGRADE_PITCH = process.env.COMMUNITY_UPGRADE_PITCH?.trim() || "В PRO: вкл/выкл клиентов, даты и расписание отключений, переименование, удаление, экспорт .conf, каскад, Cloudflare WARP, синхронизация времени хоста. Полная сборка открыта в публичном репозитории amnezia_web-PRO."; function editionPayload() { return { tier: IS_COMMUNITY ? "community" : "pro", readOnlyClients: IS_COMMUNITY, upgradeUrl: IS_COMMUNITY ? COMMUNITY_UPGRADE_URL : null, upgradePitch: IS_COMMUNITY ? COMMUNITY_UPGRADE_PITCH : null, showDebugWg: !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-awg", 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 ENV_PROFILES = parseProfilesFromEnv(); const INSTANCES_DIR = process.env.INSTANCES_DIR || "/opt/amnezia-instances"; const INSTANCES_FILE = `${process.env.DATA_DIR || "/data"}/instances.json`; const INSTANCE_SCRIPT = `${process.env.APP_DIR || "/app"}/scripts/awg-instance.sh`; const INSTANCE_VARIANTS = { awg2: { label: "AmneziaWG 2.0", desc: "Новая версия протокола на основе awg-go. Расширенная обфускация (S3, S4).", iface: "awg0", binary: "awg", }, awg: { label: "AmneziaWG", desc: "Версия протокола на основе awg-go. Обфускация S1, S2.", iface: "awg0", binary: "awg", }, legacy: { label: "AmneziaWG Legacy", desc: "Оригинальная версия на ядре WireGuard. Совместима с клиентами старых версий.", iface: "wg0", binary: "wg", }, }; function loadManagedProfiles() { try { const raw = fs.readFileSync(INSTANCES_FILE, "utf-8"); const arr = JSON.parse(raw); if (!Array.isArray(arr)) return []; const mapped = arr.map((m) => ({ id: String(m.id), label: String(m.label || m.id), container: String(m.container || m.id), confPath: String(m.confPath || `/opt/amnezia/awg/${m.iface || "awg0"}.conf`), clientsPath: String(m.clientsPath || "/opt/amnezia/awg/clientsTable"), iface: String(m.iface || "awg0"), wgBinary: String(m.wgBinary || "awg"), pskPath: String(m.pskPath || "/opt/amnezia/awg/wireguard_psk.key"), warpDir: "/opt/warp", warpConf: "/opt/warp/warp.conf", warpClientsList: "/opt/warp/clients.list", startScript: "/opt/amnezia/awg/start.sh", managed: true, variant: String(m.variant || "awg2"), port: Number(m.port) || null, })); const seen = new Set(); return mapped.filter((p) => { const key = profileIdentityKey(p); if (seen.has(key)) return false; seen.add(key); return true; }); } catch { return []; } } function saveManagedProfiles(list) { fs.mkdirSync(path.dirname(INSTANCES_FILE), { recursive: true }); const seen = new Set(); const deduped = []; for (const p of list) { const key = profileIdentityKey(p); if (seen.has(key)) continue; seen.add(key); deduped.push(p); } fs.writeFileSync(INSTANCES_FILE, JSON.stringify(deduped, null, 2)); } function profileIdentityKey(p) { return [ String(p.container || "").trim(), String(p.confPath || "").trim(), String(p.iface || "").trim(), String(p.clientsPath || "").trim(), ].join("|"); } // Effective profile set = env profiles + managed instances (deduped by id and target files). function getProfiles() { const managed = loadManagedProfiles(); const seenIds = new Set(); const seenTargets = new Set(); const out = []; for (const p of [...ENV_PROFILES, ...managed]) { const id = String(p.id || "").trim(); const target = profileIdentityKey(p); if ((id && seenIds.has(id)) || (target !== "|||" && seenTargets.has(target))) continue; out.push(p); if (id) seenIds.add(id); if (target !== "|||") seenTargets.add(target); } return out; } const PROFILES = ENV_PROFILES; // boot-time check below uses env only if (!ENV_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"); 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 defaultPassword = process.env.DEFAULT_ADMIN_PASSWORD || "admin"; passwordHashStored = hashPassword(defaultPassword); fs.writeFileSync(PW_FILE, `${passwordHashStored}\n`, { mode: 0o600 }); console.warn("Первый вход: admin / admin. Смените пароль сразу после входа."); } 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 и синхронизация времени хоста.", upgradeRequired: true, upgradeUrl: COMMUNITY_UPGRADE_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 = getProfiles().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", await rt.resolveContainer(), "test", "-f", remotePath]); return true; } catch { return false; } } async function warpInterfaceUp(rt) { try { await execDocker(["exec", await rt.resolveContainer(), "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(await rt.resolveContainer(), 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(await rt.resolveContainer(), 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; } async function listRunningContainerNames() { try { const { stdout } = await execDocker(["ps", "--format", "{{.Names}}"]); return stdout.split("\n").map((x) => x.trim()).filter(Boolean); } catch { return []; } } async function containerHasFile(name, filePath) { try { await execDocker(["exec", name, "test", "-f", filePath]); return true; } catch { return false; } } // Find a running container that actually holds the AmneziaWG config file. // Lets the panel work even if AWG_CONTAINER points at the wrong name // (e.g. configured "amnezia-awg" but Amnezia created "amnezia-awg2"). async function discoverAwgContainer(confPath, preferredName) { const names = await listRunningContainerNames(); const score = (n) => { if (preferredName && n === preferredName) return 0; if (/^amnezia-?awg/i.test(n)) return 1; if (/^amnezia/i.test(n)) return 2; if (/awg|wireguard|wg/i.test(n)) return 3; return 4; }; const ordered = [...names].sort((a, b) => score(a) - score(b)); for (const n of ordered) { if (await containerHasFile(n, confPath)) return n; } return null; } function createRuntime(profile) { let resolvedContainer = null; const confPath = profile.confPath; const clientsPath = profile.clientsPath; const iface = profile.iface; const wgBinary = profile.wgBinary; const pskPath = profile.pskPath; async function resolveContainer() { if (resolvedContainer) return resolvedContainer; if (profile.container && (await containerHasFile(profile.container, confPath))) { resolvedContainer = profile.container; return resolvedContainer; } const found = await discoverAwgContainer(confPath, profile.container); if (found) { resolvedContainer = found; if (found !== profile.container) { console.log( `\u2192 AWG container авто: «${found}» (профиль «${profile.label || profile.id}» указывал «${profile.container}»)`, ); } return resolvedContainer; } const running = (await listRunningContainerNames()).join(", ") || "(нет запущенных)"; throw new Error( `Контейнер AmneziaWG не найден. Профиль указывает «${profile.container}», но контейнера с таким именем нет и ни в одном запущенном нет файла ${confPath}. Запущенные контейнеры: ${running}. Проверьте, что инстанс Amnezia запущен, или задайте AWG_CONTAINER/AWG_PROFILES.`, ); } async function dockerExec(cmd) { const container = await resolveContainer(); const { stdout, stderr } = await execDocker(["exec", container, "sh", "-c", cmd]); return stdout + stderr; } async function dockerReadFile(remotePath) { const container = await resolveContainer(); const { stdout } = await execDocker(["exec", container, "cat", remotePath]); return stdout; } async function dockerWriteFile(remotePath, content) { const container = await resolveContainer(); await execDocker( [ "exec", "-i", container, "sh", "-c", `cat > '${remotePath}.tmp' && mv '${remotePath}.tmp' '${remotePath}'`, ], content ); } async function backupRemoteFiles() { const stamp = new Date().toISOString().replace(/[:.]/g, "-"); await dockerExec(`cp '${confPath}' '${confPath}.bak-admin-${stamp}' 2>/dev/null || true`); await dockerExec( `cp '${clientsPath}' '${clientsPath}.bak-admin-${stamp}' 2>/dev/null || true` ); } async function applySyncconf() { await dockerExec( `wg-quick strip '${confPath}' > /tmp/wg-admin-strip.conf && ${wgBinary} syncconf ${iface} /tmp/wg-admin-strip.conf` ); } async function loadState() { const [confText, tableText] = await Promise.all([ dockerReadFile(confPath), dockerReadFile(clientsPath), ]); const conf = splitAwgConf(confText); const clients = parseClientsTable(tableText); const peerByKey = new Map(conf.peers.map((p) => [p.publicKey, p])); return { confText, conf, clients, peerByKey }; } async function inferPskFromConf(conf) { if (conf.peers.length) return conf.peers[0].presharedKey; try { const text = await dockerReadFile(pskPath); return text.trim(); } catch { return null; } } return { profile, resolveContainer, dockerExec, dockerReadFile, dockerWriteFile, backupRemoteFiles, applySyncconf, loadState, inferPskFromConf, confPath, clientsPath, }; } function runtimeForRequest(req) { const wanted = getProfileCookie(req); const all = getProfiles(); const profile = all.find((p) => p.id === wanted) || all[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`; } function clientRowId(row) { return String(row?.clientId ?? row?.id ?? row?.publicKey ?? row?.client_id ?? "").trim(); } function clientRowUserData(row) { return row?.userData && typeof row.userData === "object" ? row.userData : {}; } function clientDisplayNameFromRow(row, id) { const ud = clientRowUserData(row); return String(ud.clientName || row?.name || row?.clientName || `${id.slice(0, 10)}…`); } function defaultPeerClientName(peer) { const ip = String(peer?.allowedIPs || "").split(",")[0].trim().replace(/\/\d+$/, ""); return ip ? `Клиент ${ip}` : `Peer ${String(peer?.publicKey || "").slice(0, 10)}…`; } /** Совпадает с 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; } function parseWireGuardSections(text) { const sections = {}; let current = null; for (const rawLine of String(text).replace(/\r\n/g, "\n").split("\n")) { const line = rawLine.trim(); if (!line || line.startsWith("#") || line.startsWith(";")) continue; const section = line.match(/^\[([^\]]+)\]$/); if (section) { current = section[1].trim().toLowerCase(); sections[current] ||= {}; continue; } if (!current) continue; const eq = line.indexOf("="); if (eq === -1) continue; const key = line.slice(0, eq).trim(); const value = line.slice(eq + 1).trim(); sections[current][key] = value; } return sections; } function looksLikeWireGuardConfig(text) { const s = String(text || ""); return /\[Interface\]/i.test(s) && /\[Peer\]/i.test(s) && /PrivateKey\s*=/i.test(s); } function extractWireGuardConfigsFromText(raw) { const text = String(raw || "").trim(); if (!text) return []; if (looksLikeWireGuardConfig(text)) return [text]; const found = []; const seen = new Set(); const visit = (value) => { if (typeof value === "string") { const s = value.trim(); if (looksLikeWireGuardConfig(s) && !seen.has(s)) { seen.add(s); found.push(s); } return; } if (Array.isArray(value)) { for (const item of value) visit(item); return; } if (value && typeof value === "object") { for (const item of Object.values(value)) visit(item); } }; try { visit(JSON.parse(text)); } catch { return []; } return found; } function normalizeImportName(raw, fallback) { const s = String(raw || "").trim().replace(/\s+/g, " ").slice(0, 200); return s || fallback; } function parseClientAddressList(addressRaw) { const parts = String(addressRaw || "") .split(",") .map((x) => x.trim()) .filter(Boolean); if (!parts.length) throw new Error("В [Interface] импортируемого конфига нет Address."); return parts.map((part) => { const ip = part.split("/")[0].trim(); if (!parseIpv4ToParts(ip)) return part; return part.includes("/") ? part : `${ip}/32`; }); } function splitEndpointHostPort(endpoint) { const raw = String(endpoint || "").trim(); if (!raw) return {}; const m = raw.match(/^(.+):(\d{1,5})$/); if (!m) return { hostName: raw }; const port = Number(m[2]); return { hostName: m[1].replace(/^\[|\]$/g, ""), port: Number.isInteger(port) && port > 0 && port <= 65535 ? port : undefined, }; } function buildImportedLastConfig(clientConf, sections, allowedIps) { const iface = sections.interface || {}; const peer = sections.peer || {}; const endpoint = splitEndpointHostPort(peer.Endpoint); const out = { config: clientConf.trim(), client_priv_key: iface.PrivateKey, server_pub_key: peer.PublicKey, client_ip: allowedIps[0].replace(/\/\d+$/, ""), allowed_ips: String(peer.AllowedIPs || "0.0.0.0/0, ::/0") .split(",") .map((x) => x.trim()) .filter(Boolean), ...endpoint, }; if (peer.PresharedKey) out.psk_key = peer.PresharedKey; if (iface.DNS) out.dns = iface.DNS; if (iface.MTU) out.mtu = iface.MTU; for (const k of ["Jc", "Jmin", "Jmax", "S1", "S2", "S3", "S4", "H1", "H2", "H3", "H4", "I1", "I2", "I3", "I4", "I5"]) { if (iface[k]) out[k] = iface[k]; } return out; } async function importClientConfigIntoRuntime(rt, { clientConf, clientName }) { const normalized = String(clientConf || "").trim(); if (!looksLikeWireGuardConfig(normalized)) { throw new Error("Не найден WireGuard/AmneziaWG .conf: нужны секции [Interface] и [Peer]."); } const sections = parseWireGuardSections(normalized); const iface = sections.interface || {}; const peer = sections.peer || {}; if (!iface.PrivateKey) throw new Error("В [Interface] нет PrivateKey клиента."); const allowedIps = parseClientAddressList(iface.Address); const pub = await wgPubkeyFromPrivate(rt, iface.PrivateKey); const pskLine = peer.PresharedKey ? `PresharedKey = ${peer.PresharedKey}\n` : ""; const serverPeerRaw = `[Peer] PublicKey = ${pub} ${pskLine}AllowedIPs = ${allowedIps.join(", ")} `; const serverPeer = parsePeerBlock(`${serverPeerRaw}\n`); await rt.backupRemoteFiles(); const { conf, clients, peerByKey } = await rt.loadState(); const serverIface = parseInterfaceKeyValues(conf.head); if (peer.PublicKey && serverIface.PrivateKey) { const currentServerPub = await wgPubkeyFromPrivate(rt, serverIface.PrivateKey); if (String(peer.PublicKey).trim() !== currentServerPub) { throw new Error( "Этот клиентский .conf относится к другому серверу/инстансу. Выберите правильный «Инстанс» или импортируйте конфиг от текущего сервера.", ); } } const existingClientIdx = clients.findIndex((c) => clientRowId(c) === pub); const last_config = JSON.stringify(buildImportedLastConfig(normalized, sections, allowedIps)); const now = new Date().toISOString(); const name = normalizeImportName(clientName, `Импорт ${allowedIps[0].replace(/\/\d+$/, "")}`); const nextPeers = peerByKey.has(pub) ? conf.peers : [...conf.peers, serverPeer]; const nextClients = [...clients]; const userData = { ...(existingClientIdx >= 0 ? clients[existingClientIdx].userData || {} : {}), clientName: name, last_config, allowedIps: allowedIps.join(", "), importedAt: now, }; delete userData.disabled; delete userData.disabledAt; delete userData.scheduledTunnelDisconnectAt; const rowPatch = { clientId: pub, userData, }; if (existingClientIdx >= 0) { nextClients[existingClientIdx] = { ...clients[existingClientIdx], ...rowPatch }; } else { rowPatch.userData.creationDate = now; nextClients.push(rowPatch); } await rt.dockerWriteFile(rt.confPath, serializeAwgConf(conf.head, nextPeers)); await rt.dockerWriteFile(rt.clientsPath, stringifyClientsTable(nextClients)); await rt.applySyncconf(); return { clientId: pub, name, allowedIps, peerAdded: !peerByKey.has(pub), tableUpdated: true, }; } /** Имя файла только из 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 === clientRowId(row)); 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() || "1.1.1.1"); const dns2 = String(pickLc(lc, "dns2") || process.env.CLIENT_EXPORT_DNS2?.trim() || "1.0.0.1"); 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); const iLines = [ ["I1", I1], ["I2", I2], ["I3", I3], ["I4", I4], ["I5", I5], ] .filter(([, v]) => String(v ?? "").trim() !== "") .map(([k, v]) => `${k} = ${v}`) .join("\n"); const iBlock = iLines ? `${iLines}\n` : ""; 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} ${iBlock}${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); let idx = clients.findIndex((c) => clientRowId(c) === clientId); const peerRow = { clientId, userData: {} }; if (idx === -1) { clients.push(peerRow); idx = clients.length - 1; } const ud = { ...clientRowUserData(clients[idx]) }; 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; const clientId = clientRowId(c); if (!iso || !peerByKey.get(clientId)) continue; const t = new Date(iso).getTime(); if (Number.isNaN(t) || t > now) continue; due.push({ 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 getProfiles()) { 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() { return process.env.MTPRO_PUBLIC_HOST?.trim() || process.env.CLIENT_CONFIG_ENDPOINT?.trim() || ""; } /** Хост из заголовка Host (без :порт) — фоллбек для tg://, если env не заданы. */ 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}`); } app.use(express.json({ limit: "2mb" })); app.get("/health", (_req, res) => { res.json({ ok: true, version: PANEL_VERSION }); }); 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(), }); }); function mtprotoLogsTailPayload(req) { const snap = mtprotoSnapshot(hostFromRequest(req)); if (!snap.exists || !snap.running) return { logsTail: "" }; const l = dockerSpawnSync(["logs", "--tail", "100", MTPRO_CONTAINER], 12_000); let logsTail = ""; if (l.code === 0) logsTail = l.stdout.trim().slice(-4500); return { logsTail }; } 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)); const withLogs = typeof req.query.withLogs === "string" && (req.query.withLogs === "1" || req.query.withLogs === ""); if (!withLogs) { res.json({ ...snap }); return; } const { logsTail } = mtprotoLogsTailPayload(req); res.json({ ...snap, logsTail, logsFetched: true }); }); app.get("/api/mtproto/logs", requireAuth, (req, res) => { if (effectiveUiHidden().mtproto) { return res.status(403).json({ error: MSG_UI_MTProto_OFF }); } res.json(mtprotoLogsTailPayload(req)); }); app.get("/api/mtproto/tail", requireAuth, (req, res) => { if (effectiveUiHidden().mtproto) { return res.status(403).json({ error: MSG_UI_MTProto_OFF }); } res.json(mtprotoLogsTailPayload(req)); }); 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((await rt.resolveContainer()) || "").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 = getProfiles().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: getProfiles().map((p) => ({ id: p.id, label: p.label, container: p.container, })), singleProfile: getProfiles().length < 2, profilesPersistHint: hintSingle, edition: editionPayload(), }); }); app.post("/api/protocol", requireAuth, (req, res) => { const id = req.body?.profileId; if (typeof id !== "string" || !getProfiles().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 = runtimeFromExportRequest(req); try { let wgShow = ""; try { wgShow = await rt.dockerExec(`${rt.profile.wgBinary} show ${rt.profile.iface}`); } 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 = []; const seenClientIds = new Set(); for (const c of clients) { const id = clientRowId(c); if (!id) continue; seenClientIds.add(id); const peer = peerByKey.get(id); const ud = clientRowUserData(c); const activeInConf = !!peer; rows.push({ clientId: id, name: clientDisplayNameFromRow(c, id), 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), source: "clientsTable", }); } for (const peer of conf.peers) { const id = peer.publicKey; if (!id || seenClientIds.has(id)) continue; rows.push({ clientId: id, name: `Peer ${id.slice(0, 10)}…`, allowedIps: peer.allowedIPs || null, activeInConf: true, disabled: false, disabledAt: null, lastDisconnectedAt: null, scheduledTunnelDisconnectAt: null, creationDate: null, latestHandshake: null, dataReceived: null, dataSent: null, warpEnabled: Boolean(warpMeta.supported && warpMeta.installed) && peerUsesWarp(peer, warpSelected), exportAvailable: false, source: "peer", missingClientTable: true, }); } 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: (await rt.resolveContainer().catch(() => 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) }); } }); app.post("/api/clients/sync-peers", requireAuth, requireProTier, async (req, res) => { const rt = runtimeFromExportRequest(req); try { await rt.backupRemoteFiles(); const { conf, clients } = await rt.loadState(); const existing = new Set(clients.map(clientRowId).filter(Boolean)); const now = new Date().toISOString(); const added = []; for (const peer of conf.peers) { const id = peer.publicKey; if (!id || existing.has(id)) continue; const row = { clientId: id, userData: { clientName: defaultPeerClientName(peer), creationDate: now, importedFromPeerAt: now, allowedIps: peer.allowedIPs || "", }, }; clients.push(row); existing.add(id); added.push({ clientId: id, name: row.userData.clientName, allowedIps: row.userData.allowedIps }); } if (added.length) { await rt.dockerWriteFile(rt.clientsPath, stringifyClientsTable(clients)); } res.json({ ok: true, added: added.length, totalPeers: conf.peers.length, totalClientsTable: clients.length, clients: added, }); } 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 (getProfiles().length > 1) { const pid = typeof req.query.profileId === "string" ? req.query.profileId.trim() : ""; const p = getProfiles().find((x) => x.id === pid); if (!p) { res.status(400).json({ error: "При нескольких инстансах укажите в URL параметр profileId (как в списке «Инстанс» в панели).", }); return; } rt = createRuntime(p); } else { rt = createRuntime(getProfiles()[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) => clientRowId(c) === 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", requireAuth, requireProTier, async (req, res) => { const rt = runtimeFromExportRequest(req); 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; const envHost = process.env.CLIENT_CONFIG_ENDPOINT?.trim(); const hdrHost = typeof req.headers.host === "string" ? req.headers.host.split(":")[0].trim() : ""; const endpointHost = envHost || (hdrHost && hdrHost !== "localhost" ? hdrHost : ""); if (!endpointHost) { res.status(400).json({ error: "Не удалось определить публичный адрес сервера. Задайте CLIENT_CONFIG_ENDPOINT (IP/DNS этого VPS) для контейнера панели.", }); return; } const defaultPort = rt.profile.wgBinary === "awg" ? 55424 : 51820; const endpointPort = Number.isFinite(listenPort) && listenPort > 0 ? listenPort : defaultPort; 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) => clientRowId(c) === 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-${baseName}.conf"`); res.send(text); } catch (e) { console.error(e); res.status(500).json({ error: String(e.message || e) }); } }); 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) => clientRowId(c) === 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/clients/import-config", requireAuth, requireProTier, async (req, res) => { const rt = runtimeFromExportRequest(req); try { const raw = req.body?.configText ?? req.body?.backupText ?? req.body?.text ?? ""; const configs = extractWireGuardConfigsFromText(raw); if (!configs.length) { res.status(400).json({ error: "Не нашёл .conf в тексте. Вставьте WireGuard/AmneziaWG конфиг с секциями [Interface] и [Peer] или JSON backup, где такой конфиг хранится строкой.", }); return; } const limit = Math.min(configs.length, 20); const prefix = normalizeImportName(req.body?.clientName || req.body?.namePrefix, "Импорт"); const imported = []; for (let i = 0; i < limit; i++) { const name = configs.length === 1 ? prefix : `${prefix} ${i + 1}`; imported.push(await importClientConfigIntoRuntime(rt, { clientConf: configs[i], clientName: name })); } res.json({ ok: true, found: configs.length, imported }); } 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 = runtimeFromExportRequest(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 = runtimeFromExportRequest(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) => clientRowId(c) === 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 = runtimeFromExportRequest(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(); let idx = clients.findIndex((c) => clientRowId(c) === clientId); const peer = peerByKey.get(clientId); if (idx === -1) { if (!peer) return res.status(404).json({ error: "Client not in clientsTable" }); clients.push({ clientId, userData: { allowedIps: peer.allowedIPs || "" } }); idx = clients.length - 1; } const ud = { ...clientRowUserData(clients[idx]) }; 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 = runtimeFromExportRequest(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, peerByKey } = await rt.loadState(); let idx = clients.findIndex((c) => clientRowId(c) === clientId); if (idx === -1) { const peer = peerByKey.get(clientId); if (!peer) return res.status(404).json({ error: "Client not in clientsTable" }); clients.push({ clientId, userData: { allowedIps: peer.allowedIPs || "" } }); idx = clients.length - 1; } const ud = { ...clientRowUserData(clients[idx]), 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, requireProTier, async (req, res) => { const rt = runtimeFromExportRequest(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) => clientRowId(c) !== clientId); if (nextClients.length === clients.length && nextPeers.length === conf.peers.length) { return res.status(404).json({ error: "Client not found" }); } 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"); } }, }), ); } // ───────────────────────── Managed AmneziaWG instances ───────────────────────── function runInstanceScript(args) { return new Promise((resolve, reject) => { const child = spawn("bash", [INSTANCE_SCRIPT, ...args], { env: { ...process.env, INSTANCES_DIR }, }); let out = "", 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); else reject(new Error((err || out || `exit ${code}`).trim())); }); }); } function detectVariantFromHead(head, iface) { if (iface === "wg0" || !/^S1\s*=/m.test(head)) return "legacy"; if (/^S3\s*=/m.test(head) || /^S4\s*=/m.test(head)) return "awg2"; return "awg"; } function inferVariantFromProfile(prof) { const raw = `${prof.variant || ""} ${prof.id || ""} ${prof.container || ""} ${prof.label || ""}`.toLowerCase(); if (raw.includes("legacy") || prof.iface === "wg0") return "legacy"; if (raw.includes("awg2") || raw.includes("2.0")) return "awg2"; return "awg"; } app.get("/api/instances", requireAuth, async (_req, res) => { const managedIds = new Set(loadManagedProfiles().map((m) => m.id)); const profiles = getProfiles(); const running = await listRunningContainerNames(); const items = []; const seenRuntimeTargets = new Set(); for (const prof of profiles) { const isManaged = managedIds.has(prof.id) || prof.managed === true; let container = prof.container; let runningNow = false; let peers = null; let port = prof.port || null; let variant = prof.variant || inferVariantFromProfile(prof); try { const rt = createRuntime(prof); container = await rt.resolveContainer(); runningNow = running.includes(container); if (runningNow) { const { conf } = await rt.loadState(); peers = conf.peers.length; const m = /^ListenPort\s*=\s*(\d+)/m.exec(conf.head); if (m) port = Number(m[1]); if (!prof.variant) variant = detectVariantFromHead(conf.head, prof.iface || "awg0"); } } catch { runningNow = container ? running.includes(container) : false; } const runtimeTarget = [container, prof.confPath, prof.iface, prof.clientsPath].join("|"); if (seenRuntimeTargets.has(runtimeTarget)) continue; seenRuntimeTargets.add(runtimeTarget); items.push({ id: prof.id, label: prof.label, variant, port, container, running: runningNow, peers, managed: isManaged, variantMeta: INSTANCE_VARIANTS[variant] || null, }); } res.json({ instances: items, variants: INSTANCE_VARIANTS }); }); app.post("/api/instances/create", requireAuth, requireProTier, async (req, res) => { const variant = String(req.body?.variant || "").trim(); const port = Number(req.body?.port); if (!INSTANCE_VARIANTS[variant]) return res.status(400).json({ error: "Неизвестный вариант протокола." }); if (!Number.isInteger(port) || port < 1 || port > 65535) return res.status(400).json({ error: "Некорректный порт (1–65535)." }); const name = `amnezia-${variant}-${port}`; try { const out = await runInstanceScript(["create", variant, String(port), name]); const meta = INSTANCE_VARIANTS[variant]; const list = loadManagedProfiles(); if (!list.some((p) => p.id === name)) { list.push({ id: name, label: `${meta.label} :${port}`, container: name, confPath: `/opt/amnezia/awg/${meta.iface}.conf`, clientsPath: "/opt/amnezia/awg/clientsTable", iface: meta.iface, wgBinary: meta.binary, pskPath: "/opt/amnezia/awg/wireguard_psk.key", variant, port, }); saveManagedProfiles(list); } res.json({ ok: true, id: name, output: out.slice(0, 4000) }); } catch (e) { res.status(500).json({ error: String(e.message || e).slice(0, 1500) }); } }); app.post("/api/instances/delete", requireAuth, requireProTier, async (req, res) => { const id = String(req.body?.id || "").trim(); const list = loadManagedProfiles(); const found = list.find((p) => p.id === id); if (!found) return res.status(404).json({ error: "Инстанс не найден." }); try { await runInstanceScript(["remove", id]); } catch (e) { console.warn("instance remove:", e); } saveManagedProfiles(list.filter((p) => p.id !== id)); res.json({ ok: true }); }); async function resolveProfileContainer(id) { const prof = getProfiles().find((p) => p.id === id); if (!prof) return null; try { return await createRuntime(prof).resolveContainer(); } catch { return prof.container || null; } } app.post("/api/instances/stop", requireAuth, requireProTier, async (req, res) => { const id = String(req.body?.id || "").trim(); const container = await resolveProfileContainer(id); if (!container) return res.status(404).json({ error: "Инстанс не найден." }); try { await execDocker(["stop", container]); res.json({ ok: true }); } catch (e) { res.status(500).json({ error: String(e.message || e) }); } }); app.post("/api/instances/start", requireAuth, requireProTier, async (req, res) => { const id = String(req.body?.id || "").trim(); const container = await resolveProfileContainer(id); if (!container) return res.status(404).json({ error: "Инстанс не найден." }); try { await execDocker(["start", container]); res.json({ ok: true }); } catch (e) { res.status(500).json({ error: String(e.message || e) }); } }); app.use((req, res) => { if (typeof req.path === "string" && req.path.startsWith("/api/")) { res.status(404).json({ error: "Not found", path: `${req.originalUrl || req.path}`, hint: "Эндпоинта нет. Проверьте GET /health (version), что запрос идёт в контейнер панели и прокси пробрасывает весь префикс /api/.", }); return; } 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);