fix: parse legacy clients table formats

This commit is contained in:
andrey271192
2026-06-21 19:29:08 +03:00
parent dfeabe02d7
commit 4e6ab2dd6a
4 changed files with 113 additions and 6 deletions

View File

@@ -40,6 +40,8 @@
Начиная с **v1.2.36** в блоке **«Пользователи»** есть кнопка **«Подтянуть из awg0.conf»**. Она создаёт недостающие строки `clientsTable` для уже активных `[Peer]`, которые были созданы в приложении Amnezia. VPN-конфиг и контейнер не пересоздаются. Экспорт `.conf` для таких пользователей появится только после импорта их клиентского `.conf`, потому приватный ключ клиента хранится в приложении, а не на сервере.
Начиная с **v1.2.37** парсер `clientsTable` поддерживает не только массив, но и object/map/nested-форматы разных установок Amnezia. Для диагностики источников есть `GET /api/clients/source-report?profileId=...`: показывает текущий контейнер, пути, количество `[Peer]`, количество строк `clientsTable`, missing/table-only счётчики и короткие префиксы ключей без публикации полных ключей.
## Лендинг не поднимается (порт 80 занят)
При ошибке 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",
"version": "1.2.36",
"version": "1.2.37",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "amnezia-admin",
"version": "1.2.36",
"version": "1.2.37",
"license": "MIT",
"dependencies": {
"express": "^4.21.2"

View File

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

111
server.js
View File

@@ -955,16 +955,73 @@ function serializeAwgConf(head, peers) {
function parseClientsTable(raw) {
const data = JSON.parse(raw);
if (!Array.isArray(data)) throw new Error("clientsTable is not an array");
return data;
return normalizeClientsTableRows(data);
}
function stringifyClientsTable(rows) {
return `${JSON.stringify(rows, null, 4)}\n`;
}
function looksLikePublicKey(s) {
return /^[A-Za-z0-9+/=_-]{32,80}$/.test(String(s || "").trim());
}
function normalizeClientTableRow(row, key = "") {
if (!row || typeof row !== "object" || Array.isArray(row)) return null;
const id = clientRowId(row) || (looksLikePublicKey(key) ? String(key).trim() : "");
if (!id) return { ...row };
const ud = clientRowUserData(row);
const userData = { ...ud };
for (const [from, to] of [
["name", "clientName"],
["clientName", "clientName"],
["allowedIps", "allowedIps"],
["allowedIPs", "allowedIps"],
["last_config", "last_config"],
["lastConfig", "last_config"],
["creationDate", "creationDate"],
]) {
if (row[from] != null && userData[to] == null) userData[to] = row[from];
}
return { ...row, clientId: id, userData };
}
function normalizeClientsTableRows(data) {
if (Array.isArray(data)) {
return data.map((row) => normalizeClientTableRow(row)).filter(Boolean);
}
if (!data || typeof data !== "object") {
throw new Error("clientsTable JSON is not an object/array");
}
for (const key of ["clients", "users", "rows", "items", "data", "clientList", "clientsTable"]) {
if (Array.isArray(data[key])) {
return data[key].map((row) => normalizeClientTableRow(row)).filter(Boolean);
}
}
const out = [];
for (const [key, value] of Object.entries(data)) {
if (value && typeof value === "object" && !Array.isArray(value)) {
const row = normalizeClientTableRow(value, key);
if (row) out.push(row);
}
}
if (out.length) return out;
throw new Error("clientsTable format not recognized");
}
function clientRowId(row) {
return String(row?.clientId ?? row?.id ?? row?.publicKey ?? row?.client_id ?? "").trim();
return String(
row?.clientId ??
row?.id ??
row?.publicKey ??
row?.public_key ??
row?.key ??
row?.client_id ??
row?.userData?.clientId ??
row?.userData?.publicKey ??
row?.userData?.public_key ??
"",
).trim();
}
function clientRowUserData(row) {
@@ -2392,6 +2449,54 @@ app.post("/api/clients/sync-peers", requireAuth, requireProTier, async (req, res
}
});
app.get("/api/clients/source-report", requireAuth, async (req, res) => {
const rt = runtimeFromExportRequest(req);
try {
const container = await rt.resolveContainer();
let confText = "";
let tableText = "";
let conf = { peers: [] };
let clients = [];
let clientsError = null;
try {
confText = await rt.dockerReadFile(rt.confPath);
conf = splitAwgConf(confText);
} catch (e) {
return res.status(500).json({ error: `Не удалось прочитать ${rt.confPath}: ${String(e.message || e)}` });
}
try {
tableText = await rt.dockerReadFile(rt.clientsPath);
clients = parseClientsTable(tableText);
} catch (e) {
clientsError = String(e.message || e);
}
const clientIds = new Set(clients.map(clientRowId).filter(Boolean));
const peerIds = new Set(conf.peers.map((p) => p.publicKey).filter(Boolean));
const missingInTable = [...peerIds].filter((id) => !clientIds.has(id));
const tableOnly = [...clientIds].filter((id) => !peerIds.has(id));
res.json({
profileId: rt.profile.id,
profileLabel: rt.profile.label,
container,
confPath: rt.confPath,
clientsPath: rt.clientsPath,
confBytes: Buffer.byteLength(confText, "utf8"),
clientsTableBytes: Buffer.byteLength(tableText, "utf8"),
peerCount: conf.peers.length,
clientsTableCount: clients.length,
clientsTableParseError: clientsError,
missingInTableCount: missingInTable.length,
tableOnlyCount: tableOnly.length,
peerIdsShort: [...peerIds].map((id) => `${id.slice(0, 10)}`),
clientIdsShort: [...clientIds].map((id) => `${id.slice(0, 10)}`),
missingInTableShort: missingInTable.map((id) => `${id.slice(0, 10)}`),
});
} catch (e) {
console.error(e);
res.status(500).json({ error: String(e.message || e) });
}
});
async function serveClientConfigExport(req, res) {
if (IS_COMMUNITY) {
res.status(403).json({