Files
amnezia_web/server.js
Андрей Бобырев 6c213ef49e feat(community): live install log panel in FREE UI + install-log API
Poll community-install-last.log after «Установить PRO» with GET
/api/community/install-log; preserve log DOM across banner redraws.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-15 01:17:47 +03:00

2501 lines
86 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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