Files
amnezia_web-PRO/server.js
Андрей Бобырев ecb64b8b33 fix: ssh time sync — drop BatchMode blocking sshpass
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 18:13:03 +03:00

1370 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import express from "express";
import { spawn } from "child_process";
import crypto from "crypto";
import path from "path";
import fs from "fs";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = Number(process.env.PORT || 3980);
const PROFILE_COOKIE = "amnezia_prof";
const SCHEDULER_MS = Number(process.env.SCHEDULE_DISCONNECT_MS || 60_000);
function parseProfilesFromEnv() {
const raw = process.env.AWG_PROFILES?.trim();
const fallback = () => {
const warpDir = (process.env.WARP_DIR || "/opt/warp").replace(/\/+$/, "") || "/opt/warp";
return [
{
id: "awg",
label: process.env.AWG_PROFILE_LABEL || "AmneziaWG",
container: process.env.AWG_CONTAINER || "amnezia-awg2",
confPath: process.env.AWG_CONF_PATH || "/opt/amnezia/awg/awg0.conf",
clientsPath: process.env.AWG_CLIENTS_PATH || "/opt/amnezia/awg/clientsTable",
iface: process.env.AWG_IFACE || "awg0",
wgBinary: process.env.AWG_BINARY || "awg",
pskPath: process.env.AWG_PSK_PATH || "/opt/amnezia/awg/wireguard_psk.key",
warpDir,
warpConf: process.env.WARP_CONF_PATH || `${warpDir}/warp.conf`,
warpClientsList: process.env.WARP_CLIENTS_LIST || `${warpDir}/clients.list`,
startScript: process.env.AMNEZIA_START_SCRIPT || "/opt/amnezia/start.sh",
},
];
};
if (!raw) return fallback();
try {
const arr = JSON.parse(raw);
if (!Array.isArray(arr) || arr.length === 0) return fallback();
return arr
.map((row, i) => {
const warpDirRaw = row.warpDir ?? "/opt/warp";
const warpDir = String(warpDirRaw).replace(/\/+$/, "") || "/opt/warp";
const warpConf = row.warpConf ? String(row.warpConf) : `${warpDir}/warp.conf`;
const warpClientsList = row.warpClientsList
? String(row.warpClientsList)
: `${warpDir}/clients.list`;
const startScript = String(row.startScript ?? "/opt/amnezia/start.sh");
return {
id: String(row.id ?? `p${i}`),
label: String(row.label ?? row.id ?? `Профиль ${i + 1}`),
container: String(row.container ?? ""),
confPath: String(row.confPath ?? row.conf ?? "/opt/amnezia/awg/awg0.conf"),
clientsPath: String(row.clientsPath ?? row.clients ?? "/opt/amnezia/awg/clientsTable"),
iface: String(row.iface ?? row.IFACE ?? "awg0"),
wgBinary: String(row.wgBinary ?? row.binary ?? "awg"),
pskPath: String(row.pskPath ?? row.psk ?? "/opt/amnezia/awg/wireguard_psk.key"),
warpDir,
warpConf,
warpClientsList,
startScript,
};
})
.filter((p) => p.container);
} catch {
console.warn("AWG_PROFILES: невалидный JSON, используется профиль по умолчанию.");
return fallback();
}
}
const PROFILES = parseProfilesFromEnv();
if (!PROFILES.length) {
console.error("Нет ни одного профиля AWG: укажите container в AWG_PROFILES или переменные по умолчанию.");
process.exit(1);
}
const DATA_DIR = process.env.DATA_DIR || "/data";
const PW_FILE = path.join(DATA_DIR, "password.hash");
const SECRET_FILE = path.join(DATA_DIR, "session.secret");
const SESSION_COOKIE = "amnezia_sess";
const SESSION_MS = 7 * 24 * 60 * 60 * 1000;
let passwordHashStored = "";
let sessionSecret = "";
function ensureDataDir() {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
function hashPassword(password) {
const salt = crypto.randomBytes(16);
const hash = crypto.scryptSync(password, salt, 64);
return `${salt.toString("hex")}:${hash.toString("hex")}`;
}
function verifyPassword(password, stored) {
const parts = stored.split(":");
if (parts.length !== 2) return false;
const salt = Buffer.from(parts[0], "hex");
const expected = Buffer.from(parts[1], "hex");
let hash;
try {
hash = crypto.scryptSync(password, salt, 64);
} catch {
return false;
}
if (hash.length !== expected.length) return false;
return crypto.timingSafeEqual(hash, expected);
}
function loadOrCreateSessionSecret() {
ensureDataDir();
if (fs.existsSync(SECRET_FILE)) {
sessionSecret = fs.readFileSync(SECRET_FILE, "utf8").trim();
if (sessionSecret.length < 32) {
throw new Error("session.secret слишком короткий — удалите файл для пересоздания");
}
return;
}
sessionSecret = crypto.randomBytes(32).toString("hex");
fs.writeFileSync(SECRET_FILE, `${sessionSecret}\n`, { mode: 0o600 });
}
function rotateSessionSecret() {
sessionSecret = crypto.randomBytes(32).toString("hex");
fs.writeFileSync(SECRET_FILE, `${sessionSecret}\n`, { mode: 0o600 });
}
function bootstrapPassword() {
ensureDataDir();
if (fs.existsSync(PW_FILE)) {
passwordHashStored = fs.readFileSync(PW_FILE, "utf8").trim();
if (!passwordHashStored) throw new Error("password.hash пуст");
return;
}
const bootstrap = process.env.ADMIN_PASSWORD || "";
if (bootstrap) {
passwordHashStored = hashPassword(bootstrap);
fs.writeFileSync(PW_FILE, `${passwordHashStored}\n`, { mode: 0o600 });
console.warn(
"Пароль сохранён в /data/password.hash. Уберите ADMIN_PASSWORD из окружения после первого старта."
);
return;
}
const legacyToken = process.env.ADMIN_TOKEN || "";
if (legacyToken) {
passwordHashStored = hashPassword(legacyToken);
fs.writeFileSync(PW_FILE, `${passwordHashStored}\n`, { mode: 0o600 });
console.warn(
"Миграция: пароль взяли из ADMIN_TOKEN и сохранили в /data/password.hash. Удалите ADMIN_TOKEN из окружения."
);
return;
}
const allowDefault =
process.env.ALLOW_DEFAULT_PASSWORD === "1" ||
process.env.ALLOW_DEFAULT_PASSWORD === "true";
const docPass = process.env.DEFAULT_ADMIN_PASSWORD || "AmneziaAdmin!ChangeMe";
if (allowDefault) {
passwordHashStored = hashPassword(docPass);
fs.writeFileSync(PW_FILE, `${passwordHashStored}\n`, { mode: 0o600 });
console.warn(
"Включён пароль по умолчанию из документации (README). Смените его в панели и отключите ALLOW_DEFAULT_PASSWORD."
);
return;
}
console.error(
"Нет пароля: задайте ADMIN_PASSWORD при первом запуске, см. README, или ALLOW_DEFAULT_PASSWORD=1 только для теста."
);
process.exit(1);
}
function signSession(payload) {
const body = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
const sig = crypto.createHmac("sha256", sessionSecret).update(body).digest("base64url");
return `${body}.${sig}`;
}
function readSession(token) {
if (!token || !sessionSecret) return null;
const dot = token.indexOf(".");
if (dot === -1) return null;
const body = token.slice(0, dot);
const sig = token.slice(dot + 1);
let expected;
try {
expected = crypto.createHmac("sha256", sessionSecret).update(body).digest("base64url");
} catch {
return null;
}
try {
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null;
} catch {
return null;
}
let payload;
try {
payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
} catch {
return null;
}
if (typeof payload.exp !== "number" || payload.exp < Date.now()) return null;
return payload;
}
function getSessionToken(req) {
const raw = req.headers.cookie || "";
for (const part of raw.split(";")) {
const p = part.trim();
if (p.startsWith(`${SESSION_COOKIE}=`)) {
return decodeURIComponent(p.slice(SESSION_COOKIE.length + 1));
}
}
return null;
}
function getProfileCookie(req) {
const raw = req.headers.cookie || "";
if (!raw) return null;
for (const part of raw.split(";")) {
const s = part.trim();
const eq = s.indexOf("=");
if (eq === -1) continue;
const k = decodeURIComponent(s.slice(0, eq).trim());
if (k !== PROFILE_COOKIE) continue;
return decodeURIComponent(s.slice(eq + 1).trim());
}
return null;
}
function cookieSecureFlag() {
return process.env.COOKIE_SECURE === "1" || process.env.COOKIE_SECURE === "true";
}
function setSessionCookie(res, token, maxAgeSec) {
const sec = cookieSecureFlag();
res.setHeader(
"Set-Cookie",
`${SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${maxAgeSec}; Path=/; HttpOnly; SameSite=Lax${sec ? "; Secure" : ""}`
);
}
function clearSessionCookie(res) {
const sec = cookieSecureFlag();
res.setHeader(
"Set-Cookie",
`${SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${sec ? "; Secure" : ""}`
);
}
function setProfileCookie(res, profileId) {
const sec = cookieSecureFlag();
res.setHeader(
"Set-Cookie",
`${PROFILE_COOKIE}=${encodeURIComponent(profileId)}; Max-Age=${31536000}; Path=/; SameSite=Lax${sec ? "; Secure" : ""}`
);
}
function requireAuth(req, res, next) {
const sess = readSession(getSessionToken(req));
if (!sess) {
res.status(401).json({ error: "Unauthorized" });
return;
}
next();
}
function 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;
}
function createRuntime(profile) {
const container = profile.container;
const confPath = profile.confPath;
const clientsPath = profile.clientsPath;
const iface = profile.iface;
const wgBinary = profile.wgBinary;
const pskPath = profile.pskPath;
async function dockerExec(cmd) {
const { stdout, stderr } = await execDocker(["exec", container, "sh", "-c", cmd]);
return stdout + stderr;
}
async function dockerReadFile(remotePath) {
const { stdout } = await execDocker(["exec", container, "cat", remotePath]);
return stdout;
}
async function dockerWriteFile(remotePath, content) {
await execDocker(
[
"exec",
"-i",
container,
"sh",
"-c",
`cat > '${remotePath}.tmp' && mv '${remotePath}.tmp' '${remotePath}'`,
],
content
);
}
async function backupRemoteFiles() {
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
await dockerExec(`cp '${confPath}' '${confPath}.bak-admin-${stamp}' 2>/dev/null || true`);
await dockerExec(
`cp '${clientsPath}' '${clientsPath}.bak-admin-${stamp}' 2>/dev/null || true`
);
}
async function applySyncconf() {
await dockerExec(
`wg-quick strip '${confPath}' > /tmp/wg-admin-strip.conf && ${wgBinary} syncconf ${iface} /tmp/wg-admin-strip.conf`
);
}
async function loadState() {
const [confText, tableText] = await Promise.all([
dockerReadFile(confPath),
dockerReadFile(clientsPath),
]);
const conf = splitAwgConf(confText);
const clients = parseClientsTable(tableText);
const peerByKey = new Map(conf.peers.map((p) => [p.publicKey, p]));
return { confText, conf, clients, peerByKey };
}
async function inferPskFromConf(conf) {
if (conf.peers.length) return conf.peers[0].presharedKey;
try {
const text = await dockerReadFile(pskPath);
return text.trim();
} catch {
return null;
}
}
return {
profile,
dockerExec,
dockerReadFile,
dockerWriteFile,
backupRemoteFiles,
applySyncconf,
loadState,
inferPskFromConf,
confPath,
clientsPath,
};
}
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`;
}
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();
const app = express();
app.use(express.json({ limit: "512kb" }));
app.get("/health", (_req, res) => {
res.json({ ok: true });
});
app.get("/api/session", (req, res) => {
if (!readSession(getSessionToken(req))) {
res.status(401).json({ ok: false });
return;
}
res.json({ ok: true });
});
app.get("/api/server-time", requireAuth, (req, res) => {
const now = new Date();
const timeZone = resolveServerClockTimeZone();
let formatted;
try {
formatted = now.toLocaleString("ru-RU", {
dateStyle: "medium",
timeStyle: "medium",
timeZone,
});
} catch {
formatted = now.toLocaleString("ru-RU", {
dateStyle: "medium",
timeStyle: "medium",
});
}
const browserTz =
typeof req.query.browserTz === "string" ? req.query.browserTz.trim() : "";
const zoneCompare = buildZoneCompare(timeZone, browserTz, now);
res.json({
iso: now.toISOString(),
formatted,
timeZone,
browserTimeZone: browserTz || null,
zoneSame: zoneCompare.sameZone,
zoneCompareHint: zoneCompare.hint,
zoneDiffMinutes: zoneCompare.diffMinutes ?? null,
});
});
app.get("/api/time-sync-capabilities", requireAuth, (_req, res) => {
res.json({
hostTimeSync: hostTimeSyncConfigured(),
sshHost: process.env.TIME_SYNC_SSH_HOST?.trim() || "172.17.0.1",
serverClockTimeZone: resolveServerClockTimeZone(),
});
});
app.post("/api/sync-host-time", requireAuth, 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/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);
res.json({
currentId: rt.profile.id,
currentLabel: rt.profile.label,
profiles: PROFILES.map((p) => ({
id: p.id,
label: p.label,
container: p.container,
})),
});
});
app.post("/api/protocol", requireAuth, (req, res) => {
const id = req.body?.profileId;
if (typeof id !== "string" || !PROFILES.some((p) => p.id === id)) {
res.status(400).json({ error: "Неизвестный profileId" });
return;
}
setProfileCookie(res, id);
res.json({ ok: true });
});
app.get("/api/clients", requireAuth, async (req, res) => {
const rt = runtimeForRequest(req);
try {
let wgShow = "";
try {
wgShow = await rt.dockerExec(`${rt.profile.wgBinary} show ${rt.profile.iface}`);
} catch {
wgShow = "";
}
const warpMeta = await warpSummaryForRt(rt);
const warpSelected = new Set(
warpMeta.supported && warpMeta.installed ? warpMeta.selectedAllowedIps : [],
);
const { conf, clients, peerByKey } = await rt.loadState();
const rows = 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),
};
});
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,
};
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,
});
} catch (e) {
console.error(e);
res.status(500).json({ error: String(e.message || e) });
}
});
app.post("/api/warp/start", requireAuth, async (req, res) => {
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 — см. 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, async (req, res) => {
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, async (req, res) => {
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, 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, 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, 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, async (req, res) => {
const rt = runtimeForRequest(req);
const clientId = req.body?.clientId;
const rawName = req.body?.name ?? req.body?.clientName;
if (!clientId) return res.status(400).json({ error: "clientId required" });
if (typeof rawName !== "string") {
return res.status(400).json({ error: "name required" });
}
const name = rawName.trim().replace(/\s+/g, " ");
if (!name) return res.status(400).json({ error: "Имя не может быть пустым" });
if (name.length > 200) {
return res.status(400).json({ error: "Имя не длиннее 200 символов" });
}
try {
const { clients } = await rt.loadState();
const idx = clients.findIndex((c) => c.clientId === clientId);
if (idx === -1) return res.status(404).json({ error: "Client not in clientsTable" });
const ud = { ...(clients[idx].userData || {}), clientName: name };
clients[idx] = { ...clients[idx], userData: ud };
await rt.dockerWriteFile(rt.clientsPath, stringifyClientsTable(clients));
res.json({ ok: true });
} catch (e) {
console.error(e);
res.status(500).json({ error: String(e.message || e) });
}
});
app.post("/api/clients/delete", requireAuth, async (req, res) => {
const rt = runtimeForRequest(req);
const clientId = req.body?.clientId;
if (!clientId) return res.status(400).json({ error: "clientId required" });
try {
await rt.backupRemoteFiles();
const { conf, clients } = await rt.loadState();
const nextPeers = conf.peers.filter((p) => p.publicKey !== clientId);
const nextClients = clients.filter((c) => c.clientId !== clientId);
if (nextClients.length === clients.length) {
return res.status(404).json({ error: "Client not in clientsTable" });
}
const nextConfText = serializeAwgConf(conf.head, nextPeers);
await rt.dockerWriteFile(rt.confPath, nextConfText);
await rt.dockerWriteFile(rt.clientsPath, stringifyClientsTable(nextClients));
await rt.applySyncconf();
res.json({ ok: true });
} catch (e) {
console.error(e);
res.status(500).json({ error: String(e.message || e) });
}
});
const pub = path.join(__dirname, "public");
if (fs.existsSync(pub)) {
app.use(
express.static(pub, {
setHeaders(res, filePath) {
const lower = filePath.toLowerCase();
if (lower.endsWith(".html") || lower.endsWith(".js") || lower.endsWith(".css")) {
res.setHeader("Cache-Control", "no-store");
}
},
}),
);
}
app.use((_req, res) => {
res.status(404).send("Not found");
});
app.listen(PORT, "0.0.0.0", () => {
const summary = PROFILES.map((p) => `${p.label}${p.container}`).join("; ");
console.log(`amnezia-admin on :${PORT} · ${summary} · data:${DATA_DIR}`);
});
setInterval(() => {
processAllScheduledDisconnects().catch((e) => console.error("scheduleDisconnect:", e));
}, SCHEDULER_MS);
setTimeout(() => {
processAllScheduledDisconnects().catch((e) => console.error("scheduleDisconnect:", e));
}, 4000);