fix: keep client names and dates when Amnezia app rewrites clientsTable

Приложение AmneziaVPN при переподключении пересобирает clientsTable из awg0.conf
и оставляет только clientId с именами «Client N» — имена, даты создания и last_config
пропадают. Панель теперь держит снимок этих полей в DATA_DIR и восстанавливает их
при следующем чтении состояния, попутно чиня файл в контейнере.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 02:10:36 +03:00
parent 8e5d51ab2c
commit 194ac8c742
3 changed files with 138 additions and 1 deletions

75
scripts/clients-meta.mjs Normal file
View File

@@ -0,0 +1,75 @@
/**
* Страховка от затирания clientsTable приложением AmneziaVPN.
*
* Приложение при переподключении/перезагрузке сервера пересобирает clientsTable
* из awg0.conf: остаются только clientId и дефолтные имена «Client N», а имена,
* даты создания и last_config, которые ведёт панель, пропадают.
*
* Панель держит свою копию этих полей в DATA_DIR (том /data переживает
* пересоздание контейнера) и возвращает их на место при следующем чтении.
*/
import fs from "fs";
import path from "path";
/** Поля userData, которые ведёт панель и которые нельзя терять. */
export const META_FIELDS = ["clientName", "creationDate", "last_config", "allowedIps"];
/** «Client 17» — дефолтное имя от приложения, оно не ценнее сохранённого. */
export function isPlaceholderName(name) {
return /^Client\s*\d+$/i.test(String(name || "").trim());
}
function isMeaningful(field, value) {
if (value == null || value === "") return false;
return field === "clientName" ? !isPlaceholderName(value) : true;
}
function metaFile(dataDir, profileId) {
const safe = String(profileId || "default").replace(/[^\w.-]/g, "_");
return path.join(dataDir, "clients-meta", `${safe}.json`);
}
export function readClientsMeta(dataDir, profileId) {
try {
const data = JSON.parse(fs.readFileSync(metaFile(dataDir, profileId), "utf8"));
return data && typeof data === "object" && !Array.isArray(data) ? data : {};
} catch {
return {};
}
}
export function writeClientsMeta(dataDir, profileId, meta) {
const file = metaFile(dataDir, profileId);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `${JSON.stringify(meta, null, 2)}\n`);
}
/**
* Возвращает клиентов с восстановленными полями и число восстановленных значений.
* Заодно обновляет снимок метаданных теми значениями, что сейчас на сервере;
* metaChanged=false — снимок писать не нужно (панель читает состояние часто).
*/
export function healClientsMeta(clients, meta) {
let restored = 0;
const before = JSON.stringify(meta);
const nextMeta = { ...meta };
const healed = clients.map((row) => {
const id = row?.clientId;
if (!id) return row;
const userData = { ...(row.userData || {}) };
const saved = nextMeta[id] || {};
for (const field of META_FIELDS) {
if (!isMeaningful(field, userData[field]) && isMeaningful(field, saved[field])) {
userData[field] = saved[field];
restored++;
}
}
const keep = {};
for (const field of META_FIELDS) {
if (isMeaningful(field, userData[field])) keep[field] = userData[field];
}
if (Object.keys(keep).length) nextMeta[id] = keep;
return { ...row, userData };
});
return { clients: healed, restored, meta: nextMeta, metaChanged: JSON.stringify(nextMeta) !== before };
}

View File

@@ -0,0 +1,43 @@
// node scripts/test-clients-meta.mjs — самопроверка страховки clientsTable
import assert from "assert";
import { healClientsMeta, isPlaceholderName } from "./clients-meta.mjs";
const good = [
{ clientId: "A=", userData: { clientName: "Mama", creationDate: "Sat Jun 27 2026", last_config: "{...}" } },
{ clientId: "B=", userData: { clientName: "teplici", creationDate: "Sat Jul 04 2026" } },
];
// 1. Снимок берёт живые значения.
const snap = healClientsMeta(good, {});
assert.equal(snap.restored, 0);
assert.equal(snap.meta["A="].clientName, "Mama");
assert.equal(snap.metaChanged, true);
// 2. Приложение затёрло таблицу — имена и даты возвращаются, новый клиент не трогается.
const wiped = [
{ clientId: "A=", userData: { clientName: "Client 0" } },
{ clientId: "B=", userData: { clientName: "Client 1" } },
{ clientId: "C=", userData: { clientName: "Client 2" } },
];
const fixed = healClientsMeta(wiped, snap.meta);
assert.equal(fixed.clients[0].userData.clientName, "Mama");
assert.equal(fixed.clients[0].userData.last_config, "{...}");
assert.equal(fixed.clients[1].userData.creationDate, "Sat Jul 04 2026");
assert.equal(fixed.clients[2].userData.clientName, "Client 2");
assert.equal(fixed.restored, 5);
// 3. Повторный прогон уже целой таблицы ничего не чинит и не переписывает снимок.
const again = healClientsMeta(fixed.clients, fixed.meta);
assert.equal(again.restored, 0);
assert.equal(again.metaChanged, false);
// 4. Переименование в панели побеждает старый снимок.
const renamed = healClientsMeta(
[{ clientId: "A=", userData: { clientName: "Мама новая" } }],
snap.meta
);
assert.equal(renamed.clients[0].userData.clientName, "Мама новая");
assert.equal(renamed.meta["A="].clientName, "Мама новая");
assert.ok(isPlaceholderName("Client 17") && !isPlaceholderName("Vorot2"));
console.log("clients-meta: ok");

View File

@@ -4,6 +4,7 @@ import crypto from "crypto";
import path from "path";
import fs from "fs";
import { fileURLToPath } from "url";
import { healClientsMeta, readClientsMeta, writeClientsMeta } from "./scripts/clients-meta.mjs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -1007,7 +1008,25 @@ function createRuntime(profile) {
dockerReadFile(activeClientsPath),
]);
const conf = splitAwgConf(confText);
const clients = parseClientsTable(tableText);
const parsed = parseClientsTable(tableText);
// ponytail: приложение AmneziaVPN пересобирает clientsTable из awg0.conf и стирает
// имена/даты/last_config — возвращаем их из снимка в /data и чиним файл на сервере.
const { clients, restored, meta, metaChanged } = healClientsMeta(
parsed,
readClientsMeta(DATA_DIR, profile.id)
);
if (metaChanged) writeClientsMeta(DATA_DIR, profile.id, meta);
if (restored) {
console.log(
`→ clientsTable: восстановлено ${restored} потерянных полей клиентов (${profile.label || profile.id})`
);
try {
await backupRemoteFiles();
await dockerWriteFile(activeClientsPath, stringifyClientsTable(clients));
} catch (e) {
console.warn(`clientsTable: восстановить файл на сервере не удалось: ${e.message}`);
}
}
const peerByKey = new Map(conf.peers.map((p) => [p.publicKey, p]));
return { confText, conf, clients, peerByKey, confPath: activeConfPath, clientsPath: activeClientsPath };
}