fix: auto-discover amnezia client sources

This commit is contained in:
andrey271192
2026-06-22 00:34:12 +03:00
parent 4e6ab2dd6a
commit b3600959ef
4 changed files with 129 additions and 22 deletions

View File

@@ -42,6 +42,8 @@
Начиная с **v1.2.37** парсер `clientsTable` поддерживает не только массив, но и object/map/nested-форматы разных установок Amnezia. Для диагностики источников есть `GET /api/clients/source-report?profileId=...`: показывает текущий контейнер, пути, количество `[Peer]`, количество строк `clientsTable`, missing/table-only счётчики и короткие префиксы ключей без публикации полных ключей. Начиная с **v1.2.37** парсер `clientsTable` поддерживает не только массив, но и object/map/nested-форматы разных установок Amnezia. Для диагностики источников есть `GET /api/clients/source-report?profileId=...`: показывает текущий контейнер, пути, количество `[Peer]`, количество строк `clientsTable`, missing/table-only счётчики и короткие префиксы ключей без публикации полных ключей.
Начиная с **v1.2.38** панель сама выбирает рабочие пути внутри контейнера: среди `/opt/amnezia/awg/awg0.conf`, `/opt/amnezia/awg/wg0.conf`, `/opt/amnezia/wireguard/wg0.conf` берётся конфиг с максимальным числом `[Peer]`; среди `/opt/amnezia/awg/clientsTable` и `/opt/amnezia/wireguard/clientsTable` берётся таблица с максимальным числом клиентов. Это закрывает установки, где приложение Amnezia создало старых клиентов в `wg0.conf`, а не в `awg0.conf`.
## Лендинг не поднимается (порт 80 занят) ## Лендинг не поднимается (порт 80 занят)
При ошибке bind `:80` используйте при установке **`SKIP_LANDING=1`** или **`LANDING_PORT=8081`** — админка на `HOST_PORT` (например 8080) от этого не зависит. При ошибке bind `:80` используйте при установке **`SKIP_LANDING=1`** или **`LANDING_PORT=8081`** — админка на `HOST_PORT` (например 8080) от этого не зависит.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "amnezia-admin", "name": "amnezia-admin",
"version": "1.2.37", "version": "1.2.38",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "amnezia-admin", "name": "amnezia-admin",
"version": "1.2.37", "version": "1.2.38",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"express": "^4.21.2" "express": "^4.21.2"

View File

@@ -1,6 +1,6 @@
{ {
"name": "amnezia-admin", "name": "amnezia-admin",
"version": "1.2.37", "version": "1.2.38",
"private": false, "private": false,
"description": "amnezia_web-PRO — веб-панель AmneziaWG: несколько инстансов, WARP, каскад/Endpoint, экспорт .conf (Docker)", "description": "amnezia_web-PRO — веб-панель AmneziaWG: несколько инстансов, WARP, каскад/Endpoint, экспорт .conf (Docker)",
"license": "MIT", "license": "MIT",

139
server.js
View File

@@ -814,21 +814,52 @@ async function discoverAwgContainer(confPath, preferredName) {
return null; return null;
} }
function uniqueList(items) {
return [...new Set(items.filter(Boolean).map(String))];
}
function ifaceFromConfPath(filePath, fallback) {
const base = path.basename(String(filePath || ""));
const m = base.match(/^([A-Za-z0-9_.-]+)\.conf$/);
return m ? m[1] : fallback;
}
function createRuntime(profile) { function createRuntime(profile) {
let resolvedContainer = null; let resolvedContainer = null;
let resolvedConfPath = null;
let resolvedClientsPath = null;
const confPath = profile.confPath; const confPath = profile.confPath;
const clientsPath = profile.clientsPath; const clientsPath = profile.clientsPath;
const iface = profile.iface; const iface = profile.iface;
const wgBinary = profile.wgBinary; const wgBinary = profile.wgBinary;
const pskPath = profile.pskPath; const pskPath = profile.pskPath;
const confCandidates = uniqueList([
confPath,
"/opt/amnezia/awg/awg0.conf",
"/opt/amnezia/awg/wg0.conf",
"/opt/amnezia/wireguard/wg0.conf",
]);
const clientsCandidates = uniqueList([
clientsPath,
"/opt/amnezia/awg/clientsTable",
"/opt/amnezia/wireguard/clientsTable",
]);
async function resolveContainer() { async function resolveContainer() {
if (resolvedContainer) return resolvedContainer; if (resolvedContainer) return resolvedContainer;
if (profile.container && (await containerHasFile(profile.container, confPath))) { if (profile.container) {
for (const candidate of confCandidates) {
if (await containerHasFile(profile.container, candidate)) {
resolvedContainer = profile.container; resolvedContainer = profile.container;
return resolvedContainer; return resolvedContainer;
} }
const found = await discoverAwgContainer(confPath, profile.container); }
}
let found = null;
for (const candidate of confCandidates) {
found = await discoverAwgContainer(candidate, profile.container);
if (found) break;
}
if (found) { if (found) {
resolvedContainer = found; resolvedContainer = found;
if (found !== profile.container) { if (found !== profile.container) {
@@ -844,6 +875,59 @@ function createRuntime(profile) {
); );
} }
async function resolveConfPath() {
if (resolvedConfPath) return resolvedConfPath;
const container = await resolveContainer();
let best = null;
for (const candidate of confCandidates) {
try {
const { stdout } = await execDocker(["exec", container, "cat", candidate]);
const parsed = splitAwgConf(stdout);
const score = parsed.peers.length * 10 + (candidate === confPath ? 1 : 0);
if (!best || score > best.score) best = { path: candidate, score };
} catch {
/* missing */
}
}
if (!best) {
throw new Error(`Не найден wg/awg config file в контейнере ${container}: ${confCandidates.join(", ")}`);
}
resolvedConfPath = best.path;
if (resolvedConfPath !== confPath) {
console.log(`→ AWG config авто: «${resolvedConfPath}» вместо «${confPath}» (${profile.label || profile.id})`);
}
return resolvedConfPath;
}
async function resolveClientsPath() {
if (resolvedClientsPath) return resolvedClientsPath;
const container = await resolveContainer();
let best = null;
for (const candidate of clientsCandidates) {
try {
const { stdout } = await execDocker(["exec", container, "cat", candidate]);
let parsed = [];
try {
parsed = parseClientsTable(stdout);
} catch {
parsed = [];
}
const score = parsed.length * 10 + (candidate === clientsPath ? 1 : 0);
if (!best || score > best.score) best = { path: candidate, score };
} catch {
/* missing */
}
}
if (!best) {
throw new Error(`Не найден clientsTable в контейнере ${container}: ${clientsCandidates.join(", ")}`);
}
resolvedClientsPath = best.path;
if (resolvedClientsPath !== clientsPath) {
console.log(`→ clientsTable авто: «${resolvedClientsPath}» вместо «${clientsPath}» (${profile.label || profile.id})`);
}
return resolvedClientsPath;
}
async function dockerExec(cmd) { async function dockerExec(cmd) {
const container = await resolveContainer(); const container = await resolveContainer();
const { stdout, stderr } = await execDocker(["exec", container, "sh", "-c", cmd]); const { stdout, stderr } = await execDocker(["exec", container, "sh", "-c", cmd]);
@@ -873,27 +957,33 @@ function createRuntime(profile) {
async function backupRemoteFiles() { async function backupRemoteFiles() {
const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const stamp = new Date().toISOString().replace(/[:.]/g, "-");
await dockerExec(`cp '${confPath}' '${confPath}.bak-admin-${stamp}' 2>/dev/null || true`); const activeConfPath = await resolveConfPath();
const activeClientsPath = await resolveClientsPath();
await dockerExec(`cp '${activeConfPath}' '${activeConfPath}.bak-admin-${stamp}' 2>/dev/null || true`);
await dockerExec( await dockerExec(
`cp '${clientsPath}' '${clientsPath}.bak-admin-${stamp}' 2>/dev/null || true` `cp '${activeClientsPath}' '${activeClientsPath}.bak-admin-${stamp}' 2>/dev/null || true`
); );
} }
async function applySyncconf() { async function applySyncconf() {
const activeConfPath = await resolveConfPath();
const activeIface = ifaceFromConfPath(activeConfPath, iface);
await dockerExec( await dockerExec(
`wg-quick strip '${confPath}' > /tmp/wg-admin-strip.conf && ${wgBinary} syncconf ${iface} /tmp/wg-admin-strip.conf` `wg-quick strip '${activeConfPath}' > /tmp/wg-admin-strip.conf && ${wgBinary} syncconf ${activeIface} /tmp/wg-admin-strip.conf`
); );
} }
async function loadState() { async function loadState() {
const activeConfPath = await resolveConfPath();
const activeClientsPath = await resolveClientsPath();
const [confText, tableText] = await Promise.all([ const [confText, tableText] = await Promise.all([
dockerReadFile(confPath), dockerReadFile(activeConfPath),
dockerReadFile(clientsPath), dockerReadFile(activeClientsPath),
]); ]);
const conf = splitAwgConf(confText); const conf = splitAwgConf(confText);
const clients = parseClientsTable(tableText); const clients = parseClientsTable(tableText);
const peerByKey = new Map(conf.peers.map((p) => [p.publicKey, p])); const peerByKey = new Map(conf.peers.map((p) => [p.publicKey, p]));
return { confText, conf, clients, peerByKey }; return { confText, conf, clients, peerByKey, confPath: activeConfPath, clientsPath: activeClientsPath };
} }
async function inferPskFromConf(conf) { async function inferPskFromConf(conf) {
@@ -916,8 +1006,17 @@ function createRuntime(profile) {
applySyncconf, applySyncconf,
loadState, loadState,
inferPskFromConf, inferPskFromConf,
confPath, get confPath() {
clientsPath, return resolvedConfPath || confPath;
},
get iface() {
return ifaceFromConfPath(resolvedConfPath || confPath, iface);
},
resolveConfPath,
resolveClientsPath,
get clientsPath() {
return resolvedClientsPath || clientsPath;
},
}; };
} }
@@ -2313,7 +2412,7 @@ app.get("/api/clients", requireAuth, async (req, res) => {
try { try {
let wgShow = ""; let wgShow = "";
try { try {
wgShow = await rt.dockerExec(`${rt.profile.wgBinary} show ${rt.profile.iface}`); wgShow = await rt.dockerExec(`${rt.profile.wgBinary} show ${rt.iface}`);
} catch { } catch {
wgShow = ""; wgShow = "";
} }
@@ -2321,7 +2420,7 @@ app.get("/api/clients", requireAuth, async (req, res) => {
const warpSelected = new Set( const warpSelected = new Set(
warpMeta.supported && warpMeta.installed ? warpMeta.selectedAllowedIps : [], warpMeta.supported && warpMeta.installed ? warpMeta.selectedAllowedIps : [],
); );
const { conf, clients, peerByKey } = await rt.loadState(); const { conf, clients, peerByKey, confPath: activeConfPath, clientsPath: activeClientsPath } = await rt.loadState();
const rows = []; const rows = [];
const seenClientIds = new Set(); const seenClientIds = new Set();
for (const c of clients) { for (const c of clients) {
@@ -2395,6 +2494,9 @@ app.get("/api/clients", requireAuth, async (req, res) => {
profileId: rt.profile.id, profileId: rt.profile.id,
profileLabel: rt.profile.label, profileLabel: rt.profile.label,
container: (await rt.resolveContainer().catch(() => rt.profile.container)), container: (await rt.resolveContainer().catch(() => rt.profile.container)),
confPath: activeConfPath || rt.confPath,
clientsPath: activeClientsPath || rt.clientsPath,
iface: rt.iface,
protocol: "AmneziaWG", protocol: "AmneziaWG",
peerCount: conf.peers.length, peerCount: conf.peers.length,
clients: rows, clients: rows,
@@ -2453,19 +2555,21 @@ app.get("/api/clients/source-report", requireAuth, async (req, res) => {
const rt = runtimeFromExportRequest(req); const rt = runtimeFromExportRequest(req);
try { try {
const container = await rt.resolveContainer(); const container = await rt.resolveContainer();
const activeConfPath = await rt.resolveConfPath();
const activeClientsPath = await rt.resolveClientsPath();
let confText = ""; let confText = "";
let tableText = ""; let tableText = "";
let conf = { peers: [] }; let conf = { peers: [] };
let clients = []; let clients = [];
let clientsError = null; let clientsError = null;
try { try {
confText = await rt.dockerReadFile(rt.confPath); confText = await rt.dockerReadFile(activeConfPath);
conf = splitAwgConf(confText); conf = splitAwgConf(confText);
} catch (e) { } catch (e) {
return res.status(500).json({ error: `Не удалось прочитать ${rt.confPath}: ${String(e.message || e)}` }); return res.status(500).json({ error: `Не удалось прочитать ${activeConfPath}: ${String(e.message || e)}` });
} }
try { try {
tableText = await rt.dockerReadFile(rt.clientsPath); tableText = await rt.dockerReadFile(activeClientsPath);
clients = parseClientsTable(tableText); clients = parseClientsTable(tableText);
} catch (e) { } catch (e) {
clientsError = String(e.message || e); clientsError = String(e.message || e);
@@ -2478,8 +2582,9 @@ app.get("/api/clients/source-report", requireAuth, async (req, res) => {
profileId: rt.profile.id, profileId: rt.profile.id,
profileLabel: rt.profile.label, profileLabel: rt.profile.label,
container, container,
confPath: rt.confPath, confPath: activeConfPath,
clientsPath: rt.clientsPath, iface: rt.iface,
clientsPath: activeClientsPath,
confBytes: Buffer.byteLength(confText, "utf8"), confBytes: Buffer.byteLength(confText, "utf8"),
clientsTableBytes: Buffer.byteLength(tableText, "utf8"), clientsTableBytes: Buffer.byteLength(tableText, "utf8"),
peerCount: conf.peers.length, peerCount: conf.peers.length,