mirror of
https://github.com/andrey271192/amnezia_web-PRO.git
synced 2026-09-20 14:42:00 +00:00
972 lines
32 KiB
JavaScript
972 lines
32 KiB
JavaScript
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 = () => [
|
||
{
|
||
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",
|
||
},
|
||
];
|
||
if (!raw) return fallback();
|
||
try {
|
||
const arr = JSON.parse(raw);
|
||
if (!Array.isArray(arr) || arr.length === 0) return fallback();
|
||
return arr
|
||
.map((row, i) => ({
|
||
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"),
|
||
}))
|
||
.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();
|
||
}
|
||
});
|
||
}
|
||
|
||
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",
|
||
"-oBatchMode=yes",
|
||
"-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 { 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,
|
||
};
|
||
});
|
||
res.json({
|
||
profileId: rt.profile.id,
|
||
profileLabel: rt.profile.label,
|
||
container: rt.profile.container,
|
||
protocol: "AmneziaWG",
|
||
peerCount: conf.peers.length,
|
||
clients: rows,
|
||
wgShow,
|
||
});
|
||
} 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);
|