diff --git a/docs/panel-guide.md b/docs/panel-guide.md index bc7f5c0..e20ceb9 100644 --- a/docs/panel-guide.md +++ b/docs/panel-guide.md @@ -36,6 +36,8 @@ Если переключатель **«Инстанс»** показывает правильный контейнер, но таблица **«Пользователи»** остаётся от другого инстанса, обновите до **v1.2.34**. Клиентские запросы (`/api/clients` и операции над пользователями) теперь всегда передают явный `profileId`, а не полагаются только на cookie выбранного профиля. +Если клиенты есть в `awg0.conf`, но не отображаются из-за отсутствия/нестандартного `clientsTable`, обновите до **v1.2.35**. Таблица **«Пользователи»** теперь показывает объединение `clientsTable + [Peer]` из серверного конфига. Такие строки помечаются подсказкой: для экспорта `.conf` нужно импортировать клиентский `.conf` через блок **«Импорт клиента»**. + ## Лендинг не поднимается (порт 80 занят) При ошибке bind `:80` используйте при установке **`SKIP_LANDING=1`** или **`LANDING_PORT=8081`** — админка на `HOST_PORT` (например 8080) от этого не зависит. diff --git a/package-lock.json b/package-lock.json index 4cb57ef..01cb019 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "amnezia-admin", - "version": "1.2.34", + "version": "1.2.35", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "amnezia-admin", - "version": "1.2.34", + "version": "1.2.35", "license": "MIT", "dependencies": { "express": "^4.21.2" diff --git a/package.json b/package.json index 4827b48..88f0d17 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "amnezia-admin", - "version": "1.2.34", + "version": "1.2.35", "private": false, "description": "amnezia_web-PRO — веб-панель AmneziaWG: несколько инстансов, WARP, каскад/Endpoint, экспорт .conf (Docker)", "license": "MIT", diff --git a/public/app.js b/public/app.js index 8176379..8efbbd6 100644 --- a/public/app.js +++ b/public/app.js @@ -942,12 +942,15 @@ function renderRows(clients) { const hintFold = document.createElement("details"); hintFold.className = "hint-mini"; const sum = document.createElement("summary"); - sum.textContent = "Нет готового .conf на сервере (last_config)"; + sum.textContent = c.missingClientTable + ? "Peer найден в конфиге, но нет записи clientsTable" + : "Нет готового .conf на сервере (last_config)"; hintFold.appendChild(sum); const exHint = document.createElement("p"); exHint.className = "muted export-missing-hint"; - exHint.textContent = - "Конфиг с сервера недоступен (нет last_config). Создайте клиента с нужным Endpoint в блоке «Новый клиент под каскад» ниже или возьмите ключ из приложения Amnezia."; + exHint.textContent = c.missingClientTable + ? "Клиент активен в awg0.conf, но веб не знает имя и last_config. Вставьте его .conf в блок «Импорт клиента», чтобы восстановить запись и экспорт." + : "Конфиг с сервера недоступен (нет last_config). Создайте клиента с нужным Endpoint в блоке «Новый клиент под каскад» ниже или возьмите ключ из приложения Amnezia."; hintFold.appendChild(exHint); nameWrap.appendChild(hintFold); } diff --git a/server.js b/server.js index 41f0ac5..72413d6 100644 --- a/server.js +++ b/server.js @@ -963,6 +963,19 @@ function stringifyClientsTable(rows) { return `${JSON.stringify(rows, null, 4)}\n`; } +function clientRowId(row) { + return String(row?.clientId ?? row?.id ?? row?.publicKey ?? row?.client_id ?? "").trim(); +} + +function clientRowUserData(row) { + return row?.userData && typeof row.userData === "object" ? row.userData : {}; +} + +function clientDisplayNameFromRow(row, id) { + const ud = clientRowUserData(row); + return String(ud.clientName || row?.name || row?.clientName || `${id.slice(0, 10)}…`); +} + /** Совпадает с defaults Amnezia Desktop (protocolConstants awg, desktop MTU). */ const AWG_EXPORT_DEFAULTS = { Jc: "3", @@ -1173,7 +1186,7 @@ ${pskLine}AllowedIPs = ${allowedIps.join(", ")} ); } } - const existingClientIdx = clients.findIndex((c) => c.clientId === pub); + const existingClientIdx = clients.findIndex((c) => clientRowId(c) === pub); const last_config = JSON.stringify(buildImportedLastConfig(normalized, sections, allowedIps)); const now = new Date().toISOString(); const name = normalizeImportName(clientName, `Импорт ${allowedIps[0].replace(/\/\d+$/, "")}`); @@ -1292,7 +1305,7 @@ async function buildClientConfExport(rt, lc, ifaceMap, req, row, conf) { ); } - const peer = conf.peers.find((p) => p.publicKey === row.clientId); + const peer = conf.peers.find((p) => p.publicKey === clientRowId(row)); const tunnelIp = tunnelClientIpv4(peer || {}, lc, row); let serverPub = pickLc(lc, "server_pub_key", "serverPubKey"); @@ -1513,9 +1526,13 @@ async function disableClient(rt, clientId, ts) { } 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 || {}) }; + let idx = clients.findIndex((c) => clientRowId(c) === clientId); + const peerRow = { clientId, userData: {} }; + if (idx === -1) { + clients.push(peerRow); + idx = clients.length - 1; + } + const ud = { ...clientRowUserData(clients[idx]) }; ud.disabled = true; ud.disabledAt = ts; ud.lastDisconnectedAt = ts; @@ -1535,10 +1552,11 @@ async function processScheduledDisconnects(rt) { for (const c of clients) { const ud = c.userData || {}; const iso = ud.scheduledTunnelDisconnectAt; - if (!iso || !peerByKey.get(c.clientId)) continue; + const clientId = clientRowId(c); + if (!iso || !peerByKey.get(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() }); + due.push({ clientId, ts: new Date(iso).toISOString() }); } if (!due.length) return; await rt.backupRemoteFiles(); @@ -2242,14 +2260,18 @@ app.get("/api/clients", requireAuth, async (req, res) => { warpMeta.supported && warpMeta.installed ? warpMeta.selectedAllowedIps : [], ); const { conf, clients, peerByKey } = await rt.loadState(); - const rows = clients.map((c) => { - const id = c.clientId; + const rows = []; + const seenClientIds = new Set(); + for (const c of clients) { + const id = clientRowId(c); + if (!id) continue; + seenClientIds.add(id); const peer = peerByKey.get(id); - const ud = c.userData || {}; + const ud = clientRowUserData(c); const activeInConf = !!peer; - return { + rows.push({ clientId: id, - name: ud.clientName || `${id.slice(0, 10)}…`, + name: clientDisplayNameFromRow(c, id), allowedIps: peer?.allowedIPs || ud.allowedIps || ud.preservedAllowedIPs || null, activeInConf, disabled: !activeInConf, @@ -2265,8 +2287,33 @@ app.get("/api/clients", requireAuth, async (req, res) => { activeInConf && peerUsesWarp(peer, warpSelected), exportAvailable: clientHasExportableLastConfig(c), - }; - }); + source: "clientsTable", + }); + } + for (const peer of conf.peers) { + const id = peer.publicKey; + if (!id || seenClientIds.has(id)) continue; + rows.push({ + clientId: id, + name: `Peer ${id.slice(0, 10)}…`, + allowedIps: peer.allowedIPs || null, + activeInConf: true, + disabled: false, + disabledAt: null, + lastDisconnectedAt: null, + scheduledTunnelDisconnectAt: null, + creationDate: null, + latestHandshake: null, + dataReceived: null, + dataSent: null, + warpEnabled: + Boolean(warpMeta.supported && warpMeta.installed) && + peerUsesWarp(peer, warpSelected), + exportAvailable: false, + source: "peer", + missingClientTable: true, + }); + } const warpOut = warpMeta.supported === false ? { supported: false } @@ -2344,7 +2391,7 @@ async function serveClientConfigExport(req, res) { } try { const { conf, clients } = await rt.loadState(); - const row = clients.find((c) => c.clientId === clientId); + const row = clients.find((c) => clientRowId(c) === clientId); if (!row) { res.status(404).json({ error: "Клиент не найден в clientsTable" }); return; @@ -2421,7 +2468,7 @@ app.post("/api/clients/create", requireAuth, requireProTier, async (req, res) => } const serverPub = await wgPubkeyFromPrivate(rt, ifaceMap.PrivateKey); const { priv, pub } = await awgGenKeypair(rt); - if (clients.some((c) => c.clientId === pub)) { + if (clients.some((c) => clientRowId(c) === pub)) { res.status(409).json({ error: "Коллизия ключей — попробуйте ещё раз." }); return; } @@ -2530,7 +2577,7 @@ app.post("/api/clients/create-cascade", requireAuth, requireProTier, async (req, const serverPub = await wgPubkeyFromPrivate(rt, ifaceMap.PrivateKey); const { priv, pub } = await awgGenKeypair(rt); - if (clients.some((c) => c.clientId === pub)) { + if (clients.some((c) => clientRowId(c) === pub)) { res.status(409).json({ error: "Коллизия ключей — попробуйте ещё раз." }); return; } @@ -2734,7 +2781,7 @@ app.post("/api/clients/enable", requireAuth, requireProTier, async (req, res) => if (existing) { return res.status(409).json({ error: "Peer already enabled" }); } - const idx = clients.findIndex((c) => c.clientId === clientId); + const idx = clients.findIndex((c) => clientRowId(c) === clientId); if (idx === -1) { return res.status(404).json({ error: "Client not in clientsTable" }); } @@ -2786,10 +2833,14 @@ app.post("/api/clients/disconnect-date", requireAuth, requireProTier, async (req 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" }); + let idx = clients.findIndex((c) => clientRowId(c) === clientId); const peer = peerByKey.get(clientId); - const ud = { ...(clients[idx].userData || {}) }; + if (idx === -1) { + if (!peer) return res.status(404).json({ error: "Client not in clientsTable" }); + clients.push({ clientId, userData: { allowedIps: peer.allowedIPs || "" } }); + idx = clients.length - 1; + } + const ud = { ...clientRowUserData(clients[idx]) }; if (scheduleTunnelDisconnect) { if (!peer) { return res.status(400).json({ @@ -2827,10 +2878,15 @@ app.post("/api/clients/rename", requireAuth, requireProTier, async (req, res) => 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 }; + const { clients, peerByKey } = await rt.loadState(); + let idx = clients.findIndex((c) => clientRowId(c) === clientId); + if (idx === -1) { + const peer = peerByKey.get(clientId); + if (!peer) return res.status(404).json({ error: "Client not in clientsTable" }); + clients.push({ clientId, userData: { allowedIps: peer.allowedIPs || "" } }); + idx = clients.length - 1; + } + const ud = { ...clientRowUserData(clients[idx]), clientName: name }; clients[idx] = { ...clients[idx], userData: ud }; await rt.dockerWriteFile(rt.clientsPath, stringifyClientsTable(clients)); res.json({ ok: true }); @@ -2848,9 +2904,9 @@ app.post("/api/clients/delete", requireAuth, requireProTier, async (req, res) => 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 nextClients = clients.filter((c) => clientRowId(c) !== clientId); + if (nextClients.length === clients.length && nextPeers.length === conf.peers.length) { + return res.status(404).json({ error: "Client not found" }); } const nextConfText = serializeAwgConf(conf.head, nextPeers); await rt.dockerWriteFile(rt.confPath, nextConfText);