diff --git a/scripts/clients-meta.mjs b/scripts/clients-meta.mjs new file mode 100644 index 0000000..daec11b --- /dev/null +++ b/scripts/clients-meta.mjs @@ -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 }; +} diff --git a/scripts/test-clients-meta.mjs b/scripts/test-clients-meta.mjs new file mode 100644 index 0000000..20d192e --- /dev/null +++ b/scripts/test-clients-meta.mjs @@ -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"); diff --git a/server.js b/server.js index 8721f8b..604b019 100644 --- a/server.js +++ b/server.js @@ -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 }; }