From f3aab6cc6e7f180c5321a366da6c560f7489db51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=91=D0=BE=D0=B1?= =?UTF-8?q?=D1=8B=D1=80=D0=B5=D0=B2?= Date: Thu, 14 May 2026 18:44:11 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20cascade=20client=20=E2=80=94=20custom?= =?UTF-8?q?=20Endpoint=20+=20AWG=5FPROFILES=20hint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/clients/create-cascade: genkeys, add peer, save last_config, download .conf - Optional tunnel IP in VPN subnet; obfuscation from server [Interface] when present - /api/protocols: singleProfile hint for missing instance selector - UI: cascade form, profile banner; README cascade section Co-authored-by: Cursor --- README.md | 6 +- public/app.js | 92 +++++++++++++++++- public/index.html | 28 ++++++ public/styles.css | 29 ++++++ server.js | 233 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 384 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 932f23b..b2e5cfa 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,10 @@ chmod +x scripts/warp-amnezia.sh Футер с ссылками (**Amnezia Admin WebUI**, Boosty, Ozon СБП, Telegram) в админке находится **внизу страницы** — прокрутите ниже таблицы. +### Каскад (свой Endpoint для клиента) + +В панели блок **«Новый клиент под каскад»**: задаёте IP/DNS и при необходимости порт — сервер **генерирует ключи**, добавляет peer в текущий инстанс AmneziaWG и отдаёт `.conf`, где **Endpoint** указывает на ваш промежуточный узел. На этом узле нужен **проброс UDP** на VPS (тот же порт, что слушает WG на сервере, либо свой порт из формы). + ### Экспорт конфигурации клиента (.conf) Если в записи клиента на сервере есть **`userData.last_config`** (JSON из приложения Amnezia с полем **`config`** — готовый текст — или с **`client_priv_key`** и ключами сервера), в таблице появятся **«Скачать .conf»**, **«Прямая ссылка»** и **«Копировать URL»**. Прямая ссылка имеет вид @@ -113,7 +117,7 @@ chmod +x scripts/warp-amnezia.sh Чтобы скачивать **без входа в панель**, задайте **`EXPORT_CONFIG_SECRET`** при установке и открывайте `/api/clients/export-config?token=ВАШ_СЕКРЕТ&clientId=…` — при **нескольких** профилях добавьте **`&profileId=…`**. Не пересылайте такую ссылку третьим лицам. -Если на VPS только «голый» `clientsTable` без `last_config`, экспорта не будет — конфиг нужно брать из приложения Amnezia на устройстве. +Если на VPS только «голый» `clientsTable` без `last_config`, готового экспорта по старым строкам не будет — используйте блок **«Новый клиент под каскад»** или приложение Amnezia на устройстве. Для корректного **`Endpoint`** задайте **`CLIENT_CONFIG_ENDPOINT`** при установке (публичный IP или домен VPS), либо убедитесь, что в `last_config` указан **`hostName`**, либо открывайте панель по тому же хосту, который клиенты должны использовать для подключения (не `localhost`). diff --git a/public/app.js b/public/app.js index bef3b06..f075d29 100644 --- a/public/app.js +++ b/public/app.js @@ -31,6 +31,8 @@ const pwNew = document.querySelector("#pw-new"); const pwNew2 = document.querySelector("#pw-new2"); const pwMsg = document.querySelector("#pw-msg"); +const profileHintEl = document.querySelector("#profile-hint"); + const dtDialog = document.querySelector("#disconnect-dt-dialog"); const dtTitle = document.querySelector("#dt-dialog-title"); const dtClientEl = document.querySelector("#dt-dialog-client"); @@ -202,6 +204,15 @@ async function loadProtocols() { try { const data = await api("/api/protocols"); protoLabel.textContent = `Протокол: ${data.currentLabel || "AmneziaWG"}`; + if (profileHintEl) { + if (data.singleProfile && typeof data.profilesPersistHint === "string" && data.profilesPersistHint) { + profileHintEl.textContent = data.profilesPersistHint; + profileHintEl.classList.remove("hidden"); + } else { + profileHintEl.textContent = ""; + profileHintEl.classList.add("hidden"); + } + } if (!data.profiles || data.profiles.length < 2) { protoSwitch.classList.add("hidden"); return; @@ -252,6 +263,11 @@ refreshBtn.addEventListener("click", () => { loadClients(); }); +const cascadeForm = document.querySelector("#cascade-form"); +if (cascadeForm) { + cascadeForm.addEventListener("submit", (ev) => void downloadCascadeConf(ev)); +} + protoSelect.addEventListener("change", async () => { try { setStatus("Смена инстанса…", false); @@ -516,7 +532,7 @@ function renderRows(clients) { const exHint = document.createElement("p"); exHint.className = "muted export-missing-hint"; exHint.textContent = - "Экспорт .conf с сервера недоступен: нет userData.last_config (ключи только в приложении Amnezia)."; + "Конфиг с сервера недоступен (нет last_config). Создайте клиента с нужным Endpoint в блоке «Новый клиент под каскад» ниже или возьмите ключ из приложения Amnezia."; nameWrap.appendChild(exHint); } nameTd.appendChild(nameWrap); @@ -728,10 +744,14 @@ function escapeHtml(s) { .replace(/"/g, """); } +function currentProfileIdValue() { + if (!protoSelect || !protoSwitch || protoSwitch.classList.contains("hidden")) return ""; + return String(protoSelect.value || "").trim(); +} + /** Query для нужного инстанса при нескольких профилях AWG_PROFILES */ function currentProfileQuerySuffix() { - if (!protoSelect || !protoSwitch || protoSwitch.classList.contains("hidden")) return ""; - const pid = String(protoSelect.value || "").trim(); + const pid = currentProfileIdValue(); return pid ? `&profileId=${encodeURIComponent(pid)}` : ""; } @@ -799,6 +819,72 @@ async function downloadClientConfig(c) { } } +async function downloadCascadeConf(ev) { + ev.preventDefault(); + const endpointEl = document.querySelector("#cascade-endpoint"); + const portEl = document.querySelector("#cascade-port"); + const tunnelEl = document.querySelector("#cascade-tunnel-ip"); + const nameEl = document.querySelector("#cascade-name"); + const endpointHost = endpointEl?.value.trim() || ""; + if (!endpointHost) { + setStatus("Укажите Endpoint (IP или DNS для клиента в каскаде).", true); + return; + } + const body = { endpointHost }; + const praw = portEl?.value.trim() ?? ""; + if (praw) { + const n = Number(praw); + if (!Number.isFinite(n) || n < 1 || n > 65535) { + setStatus("Некорректный порт Endpoint (1–65535).", true); + return; + } + body.endpointPort = n; + } + const tip = tunnelEl?.value.trim(); + if (tip) body.tunnelIp = tip; + const nm = nameEl?.value.trim(); + if (nm) body.clientName = nm; + const pid = currentProfileIdValue(); + if (pid) body.profileId = pid; + try { + setStatus("Создаю клиента на сервере и собираю .conf…", false); + const res = await fetch("/api/clients/create-cascade", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const text = await res.text(); + if (!res.ok) { + let msg = text; + try { + const j = JSON.parse(text); + msg = typeof j.error === "string" ? j.error : msg; + } catch { + /* raw */ + } + throw new Error(msg); + } + const blob = new Blob([text], { type: "text/plain;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + const safe = (nm || "cascade") + .replace(/[^\w\u0400-\u04FF\-]+/g, "_") + .slice(0, 60); + a.href = url; + a.download = `amnezia-cascade-${safe}.conf`; + a.rel = "noopener"; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + setStatus("Клиент добавлен на сервер, .conf скачан. Обновите таблицу.", false); + await loadClients(); + } catch (e) { + setStatus(String(e.message || e), true); + } +} + async function renameClient(c) { const next = prompt(`Новое имя для «${c.name}»:`, c.name); if (next === null) return; diff --git a/public/index.html b/public/index.html index 7eda1a2..d945acd 100644 --- a/public/index.html +++ b/public/index.html @@ -86,6 +86,8 @@ + +

+
+
+

Новый клиент под каскад

+
+

+ Укажите Endpoint — публичный IP или DNS узла, куда клиент будет подключаться первым шагом + (промежуточный сервер, домашний роутер с пробросом порта и т.д.). Порт по умолчанию совпадает с ListenPort этого инстанса. + Клиент будет создан на текущем сервере AmneziaWG (новые ключи); IP в туннеле можно задать явно или оставить пустым — подберём свободный в той же подсети, что у остальных. +

+
+ + + + + + + + + + + + + +
+
+

Пользователи

diff --git a/public/styles.css b/public/styles.css index 3b80ff2..f344a29 100644 --- a/public/styles.css +++ b/public/styles.css @@ -528,6 +528,35 @@ tr:last-child td { max-width: 22rem; } +.profile-hint { + margin: 0 0 0.75rem; + padding: 0.65rem 0.95rem; + border-radius: 12px; + border: 1px solid rgba(251, 191, 36, 0.35); + background: rgba(251, 191, 36, 0.07); + max-width: 52rem; +} + +.cascade-intro code.inline { + font-size: 0.85em; +} + +.cascade-form { + display: grid; + gap: 0.55rem; + max-width: 28rem; + margin-top: 0.85rem; +} + +.cascade-form label { + font-size: 0.82rem; + color: var(--muted); +} + +.cascade-form input { + width: 100%; +} + .raw { margin-top: 1.25rem; color: var(--muted); diff --git a/server.js b/server.js index 5697325..fbaa7d0 100644 --- a/server.js +++ b/server.js @@ -968,6 +968,117 @@ PersistentKeepalive = ${keepAlive} `; } +function assertCascadeEndpointHost(raw) { + const s = String(raw ?? "").trim(); + if (!s || s.length > 253) { + throw new Error("Укажите IP или DNS для Endpoint (куда клиент будет стучаться в каскаде)."); + } + if (/[\s<>\"']/.test(s)) { + throw new Error("Недопустимые символы в Endpoint."); + } + return s; +} + +function parseIpv4ToParts(ip) { + const m = String(ip).trim().match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (!m) return null; + const o = [1, 2, 3, 4].map((i) => parseInt(m[i], 10)); + if (o.some((x) => x > 255 || Number.isNaN(x))) return null; + return o; +} + +async function awgGenKeypair(rt) { + const privOut = await rt.dockerExec(`${rt.profile.wgBinary} genkey`); + const priv = privOut.trim().split(/\s+/)[0]; + if (!priv || !/^[A-Za-z0-9+/=_-]+$/.test(priv)) { + throw new Error("Не удалось сгенерировать ключ клиента (genkey)."); + } + const q = priv.replace(/'/g, `'\\''`); + const pubOut = await rt.dockerExec(`printf '%s\\n' '${q}' | ${rt.profile.wgBinary} pubkey`); + const pub = pubOut.trim().split(/\s+/)[0]; + if (!pub) throw new Error("Не удалось получить публичный ключ клиента."); + return { priv, pub }; +} + +function obfuscationFieldsFromServerHead(ifaceMap) { + const keys = ["Jc", "Jmin", "Jmax", "S1", "S2", "S3", "S4", "H1", "H2", "H3", "H4", "I1", "I2", "I3", "I4", "I5"]; + const out = {}; + for (const k of keys) { + const v = ifaceMap[k]; + if (v != null && String(v).trim() !== "") { + out[k] = String(v).trim(); + } + } + return out; +} + +function collectUsedTunnelIps(conf) { + const used = new Set(); + for (const p of conf.peers) { + const raw = p.allowedIPs || ""; + const re = /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?:\/\d+)?/g; + let m; + while ((m = re.exec(raw)) !== null) { + used.add(m[1]); + } + } + return used; +} + +function inferSubnetPrefixFromConf(conf, ifaceMap) { + const addrRaw = ifaceMap.Address || ifaceMap.address; + if (addrRaw) { + const chunk = String(addrRaw).split(",")[0].trim(); + const parts = parseIpv4ToParts(chunk.split("/")[0]); + if (parts) { + return `${parts[0]}.${parts[1]}.${parts[2]}`; + } + } + for (const p of conf.peers) { + const m = String(p.allowedIPs || "").match(/(\d{1,3}\.\d{1,3}\.\d{1,3})\.\d{1,3}/); + if (m) return m[1]; + } + return "10.8.1"; +} + +function suggestNextTunnelIp(conf, ifaceMap) { + const prefix = inferSubnetPrefixFromConf(conf, ifaceMap); + const used = collectUsedTunnelIps(conf); + let maxLast = 1; + for (const ip of used) { + if (!ip.startsWith(`${prefix}.`)) continue; + const last = parseInt(ip.slice(prefix.length + 1), 10); + if (!Number.isNaN(last)) maxLast = Math.max(maxLast, last); + } + for (let last = Math.max(2, maxLast + 1); last <= 254; last++) { + const candidate = `${prefix}.${last}`; + if (!used.has(candidate)) return candidate; + } + throw new Error("Не нашёл свободный IPv4 в подсети VPN для нового клиента."); +} + +function normalizeCascadeTunnelIp(conf, ifaceMap, requested) { + const prefix = inferSubnetPrefixFromConf(conf, ifaceMap); + if (!requested || !String(requested).trim()) { + return suggestNextTunnelIp(conf, ifaceMap); + } + const stripped = String(requested).trim().replace(/\/32$/i, ""); + const parts = parseIpv4ToParts(stripped); + if (!parts) { + throw new Error("Некорректный IP туннеля (ожидается IPv4, например 10.8.1.10)."); + } + const triple = `${parts[0]}.${parts[1]}.${parts[2]}`; + if (triple !== prefix) { + throw new Error(`IP клиента должен быть в подсети ${prefix}.x как у остальных клиентов этого инстанса.`); + } + const full = `${parts[0]}.${parts[1]}.${parts[2]}.${parts[3]}`; + const used = collectUsedTunnelIps(conf); + if (used.has(full)) { + throw new Error(`Адрес ${full} уже занят другим клиентом.`); + } + return full; +} + async function disableClient(rt, clientId, ts) { await rt.backupRemoteFiles(); const { conf, clients } = await rt.loadState(); @@ -1298,6 +1409,11 @@ app.get("/api/protocols", requireAuth, (req, res) => { label: p.label, container: p.container, })), + singleProfile: PROFILES.length < 2, + profilesPersistHint: + PROFILES.length < 2 + ? "Сейчас один инстанс: при установке не передали AWG_PROFILES или не восстановился снимок. Задайте JSON профилей и запустите install.sh — он сохранится в /root/amnezia-admin.awg-profiles.json." + : "", }); }); @@ -1454,6 +1570,123 @@ app.post("/api/clients/export-config", requireAuth, (req, res) => { void serveClientConfigExport(req, res); }); +/** + * Новый клиент для каскада: генерирует ключи, добавляет peer на сервер, сохраняет last_config, + * отдаёт .conf с Endpoint = endpointHost:endpointPort (ваш промежуточный узел). + */ +app.post("/api/clients/create-cascade", requireAuth, async (req, res) => { + const rt = runtimeFromExportRequest(req); + let endpointHost; + try { + endpointHost = assertCascadeEndpointHost(req.body?.endpointHost); + } catch (e) { + res.status(400).json({ error: String(e.message || e) }); + return; + } + let endpointPort; + const rawPort = req.body?.endpointPort; + if (rawPort != null && rawPort !== "") { + endpointPort = Number(rawPort); + if (!Number.isFinite(endpointPort) || endpointPort < 1 || endpointPort > 65535) { + res.status(400).json({ error: "Некорректный порт Endpoint (1–65535)." }); + return; + } + } + try { + await rt.backupRemoteFiles(); + const { conf, clients } = await rt.loadState(); + const ifaceMap = parseInterfaceKeyValues(conf.head); + if (!ifaceMap.PrivateKey) { + res.status(400).json({ error: "В wg/awg конфиге сервера нет PrivateKey в [Interface]." }); + return; + } + + const tunnelIp = normalizeCascadeTunnelIp(conf, ifaceMap, req.body?.tunnelIp); + const listenPort = ifaceMap.ListenPort ? Number(ifaceMap.ListenPort) : NaN; + if (endpointPort == null) { + endpointPort = + Number.isFinite(listenPort) && listenPort > 0 + ? listenPort + : rt.profile.wgBinary === "awg" + ? 55424 + : 51820; + } + + const psk = await rt.inferPskFromConf(conf); + if (!psk || typeof psk !== "string") { + res.status(400).json({ error: "Не удалось определить PresharedKey (нет peer или файла psk)." }); + return; + } + + const serverPub = await wgPubkeyFromPrivate(rt, ifaceMap.PrivateKey); + const { priv, pub } = await awgGenKeypair(rt); + if (clients.some((c) => c.clientId === pub)) { + res.status(409).json({ error: "Коллизия ключей — попробуйте ещё раз." }); + return; + } + + const obf = obfuscationFieldsFromServerHead(ifaceMap); + const lc = { + client_priv_key: priv, + server_pub_key: serverPub, + psk_key: psk, + client_ip: tunnelIp, + hostName: endpointHost, + port: endpointPort, + allowed_ips: ["0.0.0.0/0", "::/0"], + ...obf, + }; + + const peerRaw = `[Peer] +PublicKey = ${pub} +PresharedKey = ${psk} +AllowedIPs = ${tunnelIp}/32 +`; + const peer = parsePeerBlock(`${peerRaw}\n`); + const nextPeers = [...conf.peers, peer]; + const nextConfText = serializeAwgConf(conf.head, nextPeers); + + const rawName = req.body?.clientName; + const clientName = + typeof rawName === "string" && rawName.trim() + ? rawName.trim().replace(/\s+/g, " ").slice(0, 200) + : `Каскад ${tunnelIp}`; + + const last_config = JSON.stringify(lc); + const newRow = { + clientId: pub, + userData: { + clientName, + creationDate: new Date().toISOString(), + last_config, + allowedIps: `${tunnelIp}/32`, + }, + }; + const nextClients = [...clients, newRow]; + + await rt.dockerWriteFile(rt.confPath, nextConfText); + await rt.dockerWriteFile(rt.clientsPath, stringifyClientsTable(nextClients)); + await rt.applySyncconf(); + + const confAfter = { ...conf, peers: nextPeers }; + let text; + try { + text = await buildClientConfExport(rt, lc, ifaceMap, req, newRow, confAfter); + } catch (e) { + res.status(500).json({ error: String(e.message || e) }); + return; + } + + const baseName = safeExportFilenamePart(clientName, pub.slice(0, 12)); + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.setHeader("Content-Disposition", `attachment; filename="amnezia-cascade-${baseName}.conf"`); + res.send(text); + } catch (e) { + console.error(e); + res.status(500).json({ error: String(e.message || e) }); + } +}); + app.post("/api/warp/start", requireAuth, async (req, res) => { const rt = runtimeForRequest(req); if (!(await warpFileExists(rt, rt.profile.warpConf))) {