From cff27e27b6fa4d395c0b1f978792cfb5591e4b4c Mon Sep 17 00:00:00 2001 From: andrey271192 <76248502+andrey271192@users.noreply.github.com> Date: Sun, 21 Jun 2026 18:22:36 +0300 Subject: [PATCH] feat: import clients from amnezia configs --- README.md | 14 +++ docs/panel-guide.md | 3 + package-lock.json | 4 +- package.json | 2 +- public/app.js | 35 ++++++++ public/index.html | 27 ++++++ public/styles.css | 14 ++- server.js | 211 +++++++++++++++++++++++++++++++++++++++++++- 8 files changed, 305 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d3fd6f7..aba16c0 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,20 @@ curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web-PRO/main/s Если на хосте уже запущено **несколько** контейнеров с именами вида **`amnezia-awg*`**, а в панели по-прежнему один инстанс — перезапустите установщик из GitHub. Он автоматически создаст **`AWG_PROFILES`** и сохранит файл **`/root/amnezia-admin.awg-profiles.json`**. Ручной JSON нужен только при нестандартных путях внутри контейнеров. +### Импорт клиента из Amnezia backup / `.conf` + +В панели есть блок **«Импорт клиента»**. Он нужен, когда клиент уже создан в приложении Amnezia, но в веб-панели его нет или у строки нет `last_config`. + +Что поддерживается: + +- вставка готового клиентского **`.conf`** с секциями `[Interface]` и `[Peer]`; +- вставка JSON backup, если внутри него есть строка с таким `.conf`; +- восстановление записи в **`clientsTable`** без дублирования peer, если peer уже есть в `awg0.conf`; +- добавление peer в текущий инстанс, если его ещё нет на сервере; +- сохранение исходного `.conf` в `userData.last_config`, чтобы дальше работали экспорт и управление из веба. + +Перед импортом выберите нужный **«Инстанс»** вверху панели. Если backup приложения зашифрован или не содержит текстовый `.conf`, сначала экспортируйте клиентский конфиг из приложения Amnezia и вставьте его в форму импорта. + ### Telegram MTProto‑прокси **Не связано с AmneziaWG.** Отдельный контейнер образа **[telegrammessenger/proxy](https://hub.docker.com/r/telegrammessenger/proxy)** на том же хосте. После входа в панель: установка, перезапуск, удаление, выбор порта на хосте, просмотр логов и ссылки **`tg://proxy`** (хост из **`MTPRO_PUBLIC_HOST`**, **`CLIENT_CONFIG_ENDPOINT`** или из URL страницы панели). diff --git a/docs/panel-guide.md b/docs/panel-guide.md index 829e9b6..854bb25 100644 --- a/docs/panel-guide.md +++ b/docs/panel-guide.md @@ -11,6 +11,7 @@ | **Cloudflare WARP** | *Необязательно.* Вывод части клиентов в интернет через интерфейс `warp` в контейнере AWG. Если WARP не ставили — статус **«Не установлен»** нормален; панель и VPN без этого работают. Установка и удаление — скрипт `scripts/warp-amnezia.sh` на хосте (`install` / **`uninstall`**), подробности в основном [README](../README.md). | | **Telegram MTProto‑прокси** | *Необязательно*, не часть AWG. Docker-контейнер официального образа **telegrammessenger/proxy**; установка из панели, ссылка **`tg://proxy`**, переменные **`MTPRO_*`** — см. основной [README](../README.md). | | **Новый клиент под каскад** | Клиент в конфиге смотрит на **промежуточный узел** по Endpoint; peer и ключи создаются **на текущем VPS** и отдаются в `.conf`. На промежуточном сервере заранее нужен проброс порта DNAT UDP до WG этого VPS ([**kaskad_web_vpn**](https://github.com/andrey271192/kaskad_web_vpn) или свой проброс); в блоке см. текст и команду установки каскад-панели. | +| **Импорт клиента** | Перенос готового клиента из `.conf` или JSON backup Amnezia, если backup содержит текстовый конфиг. Панель восстанавливает строку в `clientsTable`, сохраняет `last_config` и добавляет peer только если его ещё нет в серверном конфиге. | | **Пользователи** | Вкл/выкл peer, удаление, переименование, даты отключения; экспорт `.conf` если в записи есть `userData.last_config`. | ## Переключатель «Инстанс» не отображается @@ -61,6 +62,7 @@ - **Старые строки без `last_config`** — полный `.conf` с сервера собрать нельзя (нет приватного ключа). Используйте приложение Amnezia или блок **«Новый клиент под каскад»** (новый ключ на сервере). - **Экспорт по кнопкам** — только если в `clientsTable` есть **`userData.last_config`** с полем `config` или `client_priv_key`. +- **Импорт из приложения Amnezia** — откройте блок **«Импорт клиента»**, выберите нужный **«Инстанс»**, вставьте клиентский `.conf` или JSON backup, где внутри есть такой `.conf`, и нажмите импорт. Если peer уже есть в `awg0.conf`, панель не создаёт дубль, а только восстанавливает/обновляет строку в `clientsTable`. ### Экспорт: имя файла и прямая ссылка @@ -89,6 +91,7 @@ | POST | `/api/clients/disconnect-date` | Даты отключения / расписание | | GET/POST | `/api/clients/export-config` | Скачать `.conf`; GET — прямая ссылка (сессия); опционально `?token=…` если задан `EXPORT_CONFIG_SECRET` | | POST | `/api/clients/create-cascade` | `{ "endpointHost", "endpointPort?", "tunnelIp?", "clientName?", "profileId?" }` — новый peer и файл `.conf` | +| POST | `/api/clients/import-config` | `{ "configText", "clientName?", "profileId?" }` — импорт клиента из `.conf` или JSON backup с вложенным `.conf` | | POST | `/api/warp/host-setup` | Установка/удаление WARP на хосте по SSH: `{ "rootPassword", "cmd": "install" \| "uninstall" }` (как синхронизация времени; каталог скрипта — `WARP_SSH_INSTALL_DIR`) | | POST | `/api/warp/start` | Поднять WARP | | POST | `/api/warp/stop` | Остановить WARP | diff --git a/package-lock.json b/package-lock.json index b02d931..05f726c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "amnezia-admin", - "version": "1.2.30", + "version": "1.2.31", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "amnezia-admin", - "version": "1.2.30", + "version": "1.2.31", "license": "MIT", "dependencies": { "express": "^4.21.2" diff --git a/package.json b/package.json index c1eafec..b6f7893 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "amnezia-admin", - "version": "1.2.30", + "version": "1.2.31", "private": false, "description": "amnezia_web-PRO — веб-панель AmneziaWG: несколько инстансов, WARP, каскад/Endpoint, экспорт .conf (Docker)", "license": "MIT", diff --git a/public/app.js b/public/app.js index a8df635..591abcf 100644 --- a/public/app.js +++ b/public/app.js @@ -658,6 +658,11 @@ if (directForm) { directForm.addEventListener("submit", (ev) => void downloadDirectConf(ev)); } +const importForm = document.querySelector("#import-form"); +if (importForm) { + importForm.addEventListener("submit", (ev) => void importClientConfig(ev)); +} + const instanceForm = document.querySelector("#instance-form"); if (instanceForm) { instanceForm.addEventListener("submit", (ev) => void createInstance(ev)); @@ -1424,6 +1429,36 @@ async function downloadDirectConf(ev) { } } +async function importClientConfig(ev) { + ev.preventDefault(); + const nameEl = document.querySelector("#import-name"); + const configEl = document.querySelector("#import-config"); + const configText = configEl?.value.trim() || ""; + if (!configText) { + setStatus("Вставьте .conf или JSON backup для импорта.", true); + return; + } + const body = { configText }; + const nm = nameEl?.value.trim(); + if (nm) body.clientName = nm; + const pid = currentProfileIdValue(); + if (pid) body.profileId = pid; + try { + setStatus("Импортирую клиента в текущий инстанс…", false); + const data = await api("/api/clients/import-config", { + method: "POST", + body: JSON.stringify(body), + }); + const count = Array.isArray(data.imported) ? data.imported.length : 0; + if (configEl) configEl.value = ""; + if (nameEl) nameEl.value = ""; + setStatus(`Импортировано клиентов: ${count}. Таблица обновлена.`, false); + await loadClients(); + } catch (e) { + setStatus(String(e.message || e), true); + } +} + const VARIANT_META = { awg2: { icon: "✨", title: "AmneziaWG 2.0", badge: "NEW" }, awg: { icon: "🔮", title: "AmneziaWG", badge: "" }, diff --git a/public/index.html b/public/index.html index d0900cb..fcccf0f 100644 --- a/public/index.html +++ b/public/index.html @@ -240,6 +240,33 @@ +
+ + + + Импорт клиента + Перенос из готового .conf или backup Amnezia, где внутри есть конфиг + + +
+
+

+ Вставьте содержимое клиентского .conf или JSON backup. Панель добавит peer в текущий инстанс, + восстановит запись в clientsTable и сохранит исходный конфиг для дальнейшего экспорта. +

+
+
+ + + + + + + +
+
+
+
diff --git a/public/styles.css b/public/styles.css index 4eaefa5..f04d51e 100644 --- a/public/styles.css +++ b/public/styles.css @@ -844,10 +844,22 @@ tr:last-child td { color: var(--muted); } -.cascade-form input { +.cascade-form input, +.cascade-form textarea { width: 100%; } +.cascade-form textarea { + resize: vertical; + min-height: 12rem; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + line-height: 1.45; +} + +.import-form { + max-width: 46rem; +} + .raw { margin-top: 1.25rem; color: var(--muted); diff --git a/server.js b/server.js index 66fa2ce..472d112 100644 --- a/server.js +++ b/server.js @@ -998,6 +998,189 @@ function parseInterfaceKeyValues(head) { return out; } +function parseWireGuardSections(text) { + const sections = {}; + let current = null; + for (const rawLine of String(text).replace(/\r\n/g, "\n").split("\n")) { + const line = rawLine.trim(); + if (!line || line.startsWith("#") || line.startsWith(";")) continue; + const section = line.match(/^\[([^\]]+)\]$/); + if (section) { + current = section[1].trim().toLowerCase(); + sections[current] ||= {}; + continue; + } + if (!current) continue; + const eq = line.indexOf("="); + if (eq === -1) continue; + const key = line.slice(0, eq).trim(); + const value = line.slice(eq + 1).trim(); + sections[current][key] = value; + } + return sections; +} + +function looksLikeWireGuardConfig(text) { + const s = String(text || ""); + return /\[Interface\]/i.test(s) && /\[Peer\]/i.test(s) && /PrivateKey\s*=/i.test(s); +} + +function extractWireGuardConfigsFromText(raw) { + const text = String(raw || "").trim(); + if (!text) return []; + if (looksLikeWireGuardConfig(text)) return [text]; + + const found = []; + const seen = new Set(); + const visit = (value) => { + if (typeof value === "string") { + const s = value.trim(); + if (looksLikeWireGuardConfig(s) && !seen.has(s)) { + seen.add(s); + found.push(s); + } + return; + } + if (Array.isArray(value)) { + for (const item of value) visit(item); + return; + } + if (value && typeof value === "object") { + for (const item of Object.values(value)) visit(item); + } + }; + + try { + visit(JSON.parse(text)); + } catch { + return []; + } + return found; +} + +function normalizeImportName(raw, fallback) { + const s = String(raw || "").trim().replace(/\s+/g, " ").slice(0, 200); + return s || fallback; +} + +function parseClientAddressList(addressRaw) { + const parts = String(addressRaw || "") + .split(",") + .map((x) => x.trim()) + .filter(Boolean); + if (!parts.length) throw new Error("В [Interface] импортируемого конфига нет Address."); + return parts.map((part) => { + const ip = part.split("/")[0].trim(); + if (!parseIpv4ToParts(ip)) return part; + return part.includes("/") ? part : `${ip}/32`; + }); +} + +function splitEndpointHostPort(endpoint) { + const raw = String(endpoint || "").trim(); + if (!raw) return {}; + const m = raw.match(/^(.+):(\d{1,5})$/); + if (!m) return { hostName: raw }; + const port = Number(m[2]); + return { + hostName: m[1].replace(/^\[|\]$/g, ""), + port: Number.isInteger(port) && port > 0 && port <= 65535 ? port : undefined, + }; +} + +function buildImportedLastConfig(clientConf, sections, allowedIps) { + const iface = sections.interface || {}; + const peer = sections.peer || {}; + const endpoint = splitEndpointHostPort(peer.Endpoint); + const out = { + config: clientConf.trim(), + client_priv_key: iface.PrivateKey, + server_pub_key: peer.PublicKey, + client_ip: allowedIps[0].replace(/\/\d+$/, ""), + allowed_ips: String(peer.AllowedIPs || "0.0.0.0/0, ::/0") + .split(",") + .map((x) => x.trim()) + .filter(Boolean), + ...endpoint, + }; + if (peer.PresharedKey) out.psk_key = peer.PresharedKey; + if (iface.DNS) out.dns = iface.DNS; + if (iface.MTU) out.mtu = iface.MTU; + for (const k of ["Jc", "Jmin", "Jmax", "S1", "S2", "S3", "S4", "H1", "H2", "H3", "H4", "I1", "I2", "I3", "I4", "I5"]) { + if (iface[k]) out[k] = iface[k]; + } + return out; +} + +async function importClientConfigIntoRuntime(rt, { clientConf, clientName }) { + const normalized = String(clientConf || "").trim(); + if (!looksLikeWireGuardConfig(normalized)) { + throw new Error("Не найден WireGuard/AmneziaWG .conf: нужны секции [Interface] и [Peer]."); + } + const sections = parseWireGuardSections(normalized); + const iface = sections.interface || {}; + const peer = sections.peer || {}; + if (!iface.PrivateKey) throw new Error("В [Interface] нет PrivateKey клиента."); + const allowedIps = parseClientAddressList(iface.Address); + const pub = await wgPubkeyFromPrivate(rt, iface.PrivateKey); + const pskLine = peer.PresharedKey ? `PresharedKey = ${peer.PresharedKey}\n` : ""; + const serverPeerRaw = `[Peer] +PublicKey = ${pub} +${pskLine}AllowedIPs = ${allowedIps.join(", ")} +`; + const serverPeer = parsePeerBlock(`${serverPeerRaw}\n`); + + await rt.backupRemoteFiles(); + const { conf, clients, peerByKey } = await rt.loadState(); + const serverIface = parseInterfaceKeyValues(conf.head); + if (peer.PublicKey && serverIface.PrivateKey) { + const currentServerPub = await wgPubkeyFromPrivate(rt, serverIface.PrivateKey); + if (String(peer.PublicKey).trim() !== currentServerPub) { + throw new Error( + "Этот клиентский .conf относится к другому серверу/инстансу. Выберите правильный «Инстанс» или импортируйте конфиг от текущего сервера.", + ); + } + } + const existingClientIdx = clients.findIndex((c) => c.clientId === pub); + const last_config = JSON.stringify(buildImportedLastConfig(normalized, sections, allowedIps)); + const now = new Date().toISOString(); + const name = normalizeImportName(clientName, `Импорт ${allowedIps[0].replace(/\/\d+$/, "")}`); + + const nextPeers = peerByKey.has(pub) ? conf.peers : [...conf.peers, serverPeer]; + const nextClients = [...clients]; + const userData = { + ...(existingClientIdx >= 0 ? clients[existingClientIdx].userData || {} : {}), + clientName: name, + last_config, + allowedIps: allowedIps.join(", "), + importedAt: now, + }; + delete userData.disabled; + delete userData.disabledAt; + delete userData.scheduledTunnelDisconnectAt; + const rowPatch = { + clientId: pub, + userData, + }; + if (existingClientIdx >= 0) { + nextClients[existingClientIdx] = { ...clients[existingClientIdx], ...rowPatch }; + } else { + rowPatch.userData.creationDate = now; + nextClients.push(rowPatch); + } + + await rt.dockerWriteFile(rt.confPath, serializeAwgConf(conf.head, nextPeers)); + await rt.dockerWriteFile(rt.clientsPath, stringifyClientsTable(nextClients)); + await rt.applySyncconf(); + return { + clientId: pub, + name, + allowedIps, + peerAdded: !peerByKey.has(pub), + tableUpdated: true, + }; +} + /** Имя файла только из ASCII — иначе Node отклоняет заголовок Content-Disposition. */ function safeExportFilenamePart(name, fallback) { const toAsciiToken = (s) => @@ -1637,7 +1820,7 @@ if (UI_HIDDEN.users || UI_HIDDEN.warp || UI_HIDDEN.cascade || UI_HIDDEN.mtproto) if (IS_COMMUNITY) { console.warn(`Редакция community (только просмотр клиентов). PRO: ${COMMUNITY_UPGRADE_URL}`); } -app.use(express.json({ limit: "512kb" })); +app.use(express.json({ limit: "2mb" })); app.get("/health", (_req, res) => { res.json({ ok: true, version: PANEL_VERSION }); @@ -2382,6 +2565,32 @@ AllowedIPs = ${tunnelIp}/32 } }); +app.post("/api/clients/import-config", requireAuth, requireProTier, async (req, res) => { + const rt = runtimeFromExportRequest(req); + try { + const raw = req.body?.configText ?? req.body?.backupText ?? req.body?.text ?? ""; + const configs = extractWireGuardConfigsFromText(raw); + if (!configs.length) { + res.status(400).json({ + error: + "Не нашёл .conf в тексте. Вставьте WireGuard/AmneziaWG конфиг с секциями [Interface] и [Peer] или JSON backup, где такой конфиг хранится строкой.", + }); + return; + } + const limit = Math.min(configs.length, 20); + const prefix = normalizeImportName(req.body?.clientName || req.body?.namePrefix, "Импорт"); + const imported = []; + for (let i = 0; i < limit; i++) { + const name = configs.length === 1 ? prefix : `${prefix} ${i + 1}`; + imported.push(await importClientConfigIntoRuntime(rt, { clientConf: configs[i], clientName: name })); + } + res.json({ ok: true, found: configs.length, imported }); + } catch (e) { + console.error(e); + res.status(500).json({ error: String(e.message || e) }); + } +}); + app.post("/api/warp/start", requireAuth, requireProTier, async (req, res) => { if (UI_HIDDEN.warp) { return res.status(403).json({ error: MSG_UI_WARP_OFF });