Files
amnezia_web-PRO/server.js
2026-05-14 17:08:07 +03:00

972 lines
32 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 = () => [
{
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);