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