diff --git a/README.md b/README.md index 7e27ce7..0dd6d61 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Amnezia Admin WebUI -Веб-панель на вашем VPS для управления клиентами **AmneziaWG**: вкл/выкл, удаление, переименование, дата отключения, а также **политика выхода через Cloudflare WARP** по клиентам (без Telegram и без QR — только скрипт установки на хосте и блок в вебе). Работает через Docker и `docker exec` в контейнер **Amnezia** (по умолчанию `amnezia-awg2`). +Веб-панель на вашем VPS для управления клиентами **AmneziaWG**: вкл/выкл, удаление, переименование, дата отключения, при наличии `last_config` на сервере — **скачивание клиентского .conf**, а также **политика выхода через Cloudflare WARP** по клиентам (без Telegram и без QR — только скрипт установки на хосте и блок в вебе). Работает через Docker и `docker exec` в контейнер **Amnezia** (по умолчанию `amnezia-awg2`). **Безопасность:** контейнер с монтированием `docker.sock` эквивалентен root на хосте — используйте сложный пароль и по возможности ограничьте доступ по IP или TLS. @@ -57,6 +57,9 @@ cd /opt/amnezia-admin && chmod +x scripts/install.sh && sudo SKIP_DOWNLOAD=1 bas | `TZ` | _(часто UTC в Docker)_ | Пояс строки «Сервер» в панели (IANA, например `Europe/Berlin`). Без `TZ` берётся из образа (часто UTC) — тогда от браузера будет видна разница часов | | `TIME_SYNC_SSH_HOST` | `172.17.0.1` | Хост для SSH root при синхронизации времени из панели (часто шлюз Docker к хосту) | | `TIME_SYNC_DISABLED` | `0` | `1` — скрыть/отключить синхронизацию времени по SSH | +| `CLIENT_CONFIG_ENDPOINT` | _(нет)_ | Публичный IP или DNS VPS для строки `Endpoint` при сборке `.conf` из `last_config`, если нет `hostName` в JSON и заходите в панель не по IP | +| `CLIENT_EXPORT_DNS1` | `8.8.8.8` | DNS в экспортируемом клиентском конфиге | +| `CLIENT_EXPORT_DNS2` | `8.8.4.4` | Второй DNS в экспортируемом конфиге | | `ADMIN_PASSWORD` | _(генерируется)_ | Первый пароль вместо файла | | `SKIP_DOWNLOAD` | `0` | `1` — не качать GitHub, собрать из `INSTALL_DIR` | | `ALLOW_DEFAULT_PASSWORD` | `0` | `1` — см. раздел «Пароль» ниже | @@ -99,6 +102,12 @@ chmod +x scripts/warp-amnezia.sh Футер с ссылками (**Amnezia Admin WebUI**, Boosty, Ozon СБП, Telegram) в админке находится **внизу страницы** — прокрутите ниже таблицы. +### Экспорт конфигурации клиента (.conf) + +Если в записи клиента на сервере есть **`userData.last_config`** (JSON из приложения Amnezia с полем **`config`** — готовый текст — или с **`client_priv_key`** и ключами сервера), в таблице появится кнопка **«Скачать .conf»**. Такое содержимое сервер получает, когда клиент создавался/синхронизировался через официальное приложение; если на VPS только «голый» `clientsTable` без `last_config`, кнопки не будет — конфиг нужно брать из приложения на устройстве. + +Для корректного **`Endpoint`** задайте **`CLIENT_CONFIG_ENDPOINT`** при установке (публичный IP или домен VPS), либо убедитесь, что в `last_config` указан **`hostName`**, либо открывайте панель по тому же хосту, который клиенты должны использовать для подключения (не `localhost`). + ### Обновление до новой версии Коммиты на GitHub **сами не попадают** в уже запущенный контейнер: нужно заново **скачать код и пересобрать образ**. diff --git a/public/app.js b/public/app.js index 578a576..141ffdf 100644 --- a/public/app.js +++ b/public/app.js @@ -548,6 +548,11 @@ function renderRows(clients) { btn("Включить", "btn small primary", () => mutate("/api/clients/enable", c.clientId)) ); } + if (c.exportAvailable) { + actTd.appendChild( + btn("Скачать .conf", "btn small ghost", () => void downloadClientConfig(c)), + ); + } actTd.appendChild(btn("Удалить", "btn small warn", () => confirmDelete(c.name, c.clientId))); tr.append(nameTd, ipTd, stTd, offTd, actTd); @@ -703,6 +708,45 @@ function escapeHtml(s) { .replace(/"/g, """); } +async function downloadClientConfig(c) { + try { + setStatus("Готовлю конфиг…", false); + const res = await fetch("/api/clients/export-config", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ clientId: c.clientId }), + }); + 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 { + /* сырой текст */ + } + 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 = String(c.name || "client") + .replace(/[^\w\u0400-\u04FF\-]+/g, "_") + .slice(0, 60); + a.href = url; + a.download = `amnezia-${safe}.conf`; + a.rel = "noopener"; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + setStatus("Конфиг скачан.", false); + } 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/scripts/install.sh b/scripts/install.sh index b7b7a1d..db11606 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -119,6 +119,12 @@ for __warp_var in WARP_DIR WARP_CONF_PATH WARP_CLIENTS_LIST AMNEZIA_START_SCRIPT fi done +for __export_var in CLIENT_CONFIG_ENDPOINT CLIENT_EXPORT_DNS1 CLIENT_EXPORT_DNS2; do + if [[ -n "${!__export_var:-}" ]]; then + RUN_ENV+=( -e "${__export_var}=${!__export_var}" ) + fi +done + if [[ -n "${BOOT_PW}" ]]; then RUN_ENV+=( -e "ADMIN_PASSWORD=${BOOT_PW}" ) elif [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "1" ]] || [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "true" ]]; then diff --git a/server.js b/server.js index f089fc8..716cd2b 100644 --- a/server.js +++ b/server.js @@ -697,6 +697,243 @@ function stringifyClientsTable(rows) { return `${JSON.stringify(rows, null, 4)}\n`; } +/** Совпадает с defaults Amnezia Desktop (protocolConstants awg, desktop MTU). */ +const AWG_EXPORT_DEFAULTS = { + Jc: "3", + Jmin: "10", + Jmax: "30", + S1: "15", + S2: "18", + S3: "20", + S4: "23", + H1: "1020325451", + H2: "3288052141", + H3: "1766607858", + H4: "2528465083", + I1: "", + I2: "", + I3: "", + I4: "", + I5: "", +}; + +function parseLastConfigFromClientRow(row) { + const ud = row?.userData; + if (!ud || typeof ud !== "object") return null; + let raw = ud.last_config ?? ud.lastConfig; + if (typeof raw === "string") { + raw = raw.trim(); + if (!raw) return null; + try { + return JSON.parse(raw); + } catch { + return null; + } + } + if (raw && typeof raw === "object") return raw; + return null; +} + +function clientHasExportableLastConfig(row) { + const lc = parseLastConfigFromClientRow(row); + if (!lc) return false; + if (String(lc.config ?? lc.nativeConfig ?? "").trim()) return true; + const priv = lc.client_priv_key || lc.clientPrivKey; + return Boolean(priv && typeof priv === "string"); +} + +function pickLc(lc, ...keys) { + for (const k of keys) { + const v = lc[k]; + if (v != null && v !== "") return v; + } + return undefined; +} + +function parseInterfaceKeyValues(head) { + const out = {}; + for (const line of String(head).split("\n")) { + const t = line.trim(); + if (!t || t.startsWith("#")) continue; + const eq = t.indexOf("="); + if (eq === -1) continue; + const k = t.slice(0, eq).trim(); + const v = t.slice(eq + 1).trim(); + out[k] = v; + } + return out; +} + +function safeExportFilenamePart(name, fallback) { + const s = String(name || fallback || "client").replace(/[^\w\u0400-\u04FF\-]+/g, "_"); + return s.slice(0, 80) || "client"; +} + +function formatExportAllowedIps(lc, fallback = "0.0.0.0/0, ::/0") { + const v = lc.allowed_ips ?? lc.allowedIps; + if (Array.isArray(v)) { + const joined = v.map(String).join(", "); + return joined.trim() || fallback; + } + if (typeof v === "string" && v.trim()) return v.trim(); + return fallback; +} + +async function wgPubkeyFromPrivate(rt, privKeyB64) { + const key = String(privKeyB64).trim(); + if (!/^[A-Za-z0-9+/=_-]+$/.test(key)) { + throw new Error("Некорректный формат приватного ключа сервера в awg0.conf"); + } + const q = key.replace(/'/g, `'\\''`); + const out = await rt.dockerExec(`printf '%s\\n' '${q}' | ${rt.profile.wgBinary} pubkey`); + const pub = out.trim().split(/\s+/)[0]; + if (!pub) throw new Error("Не удалось получить публичный ключ сервера (wg pubkey)."); + return pub; +} + +function tunnelClientIpv4(peer, lc, row) { + const fromLc = pickLc(lc, "client_ip", "clientIp"); + if (fromLc) return String(fromLc).replace(/\/\d+$/, "").trim(); + if (peer?.allowedIPs) { + const m = String(peer.allowedIPs).match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/); + if (m) return m[1]; + } + const ud = row?.userData || {}; + const udIp = ud.allowedIps || ud.preservedAllowedIPs; + if (udIp) { + const m = String(udIp).match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/); + if (m) return m[1]; + } + throw new Error( + "Нет client_ip в last_config и не удалось взять IPv4 из AllowedIPs peer или из записи клиента (выключен без сохранённого адреса).", + ); +} + +function resolveExportEndpointHost(lc, req) { + const env = process.env.CLIENT_CONFIG_ENDPOINT?.trim(); + if (env) return env; + const hn = pickLc(lc, "hostName", "hostname", "host"); + if (hn && String(hn).trim()) return String(hn).trim(); + const h = req.headers.host; + if (h && typeof h === "string") { + const hostPart = h.split(":")[0].trim(); + if (hostPart && hostPart !== "localhost") return hostPart; + } + throw new Error( + "Не удалось определить Endpoint. Задайте CLIENT_CONFIG_ENDPOINT для контейнера панели (публичный IP или DNS VPS) или hostName в last_config клиента.", + ); +} + +async function buildClientConfExport(rt, lc, ifaceMap, req, row, conf) { + const native = String(lc.config ?? lc.nativeConfig ?? "").trim(); + if (native) return native; + + const priv = pickLc(lc, "client_priv_key", "clientPrivKey"); + if (!priv || typeof priv !== "string") { + throw new Error( + "В last_config нет готового текста (config) и нет client_priv_key — восстановить .conf с сервера нельзя.", + ); + } + + const peer = conf.peers.find((p) => p.publicKey === row.clientId); + const tunnelIp = tunnelClientIpv4(peer || {}, lc, row); + + let serverPub = pickLc(lc, "server_pub_key", "serverPubKey"); + if (!serverPub && ifaceMap.PrivateKey) { + serverPub = await wgPubkeyFromPrivate(rt, ifaceMap.PrivateKey); + } + if (!serverPub) { + throw new Error("Нет server_pub_key в last_config и PrivateKey в секции [Interface] сервера."); + } + + const psk = pickLc(lc, "psk_key", "pskKey"); + if (!psk || typeof psk !== "string") { + throw new Error("В last_config нет psk_key (общий ключ с сервером)."); + } + + const endpointHost = resolveExportEndpointHost(lc, req); + const listenPort = ifaceMap.ListenPort ? Number(ifaceMap.ListenPort) : NaN; + const portNum = Number(pickLc(lc, "port")) || (Number.isFinite(listenPort) ? listenPort : NaN); + const defaultPort = rt.profile.wgBinary === "awg" ? 55424 : 51820; + const port = Number.isFinite(portNum) && portNum > 0 ? portNum : defaultPort; + + const dns1 = String(pickLc(lc, "dns1") || process.env.CLIENT_EXPORT_DNS1?.trim() || "8.8.8.8"); + const dns2 = String(pickLc(lc, "dns2") || process.env.CLIENT_EXPORT_DNS2?.trim() || "8.8.4.4"); + const peerAllowed = formatExportAllowedIps(lc); + const keepAlive = String(pickLc(lc, "persistent_keep_alive", "persistentKeepAlive") || "25"); + const mtuVal = pickLc(lc, "mtu", "MTU"); + const mtuLine = mtuVal ? `MTU = ${String(mtuVal).trim()}\n` : ""; + + if (rt.profile.wgBinary === "awg") { + const Jc = String(pickLc(lc, "Jc", "junk_packet_count", "junkPacketCount") ?? AWG_EXPORT_DEFAULTS.Jc); + const Jmin = String(pickLc(lc, "Jmin", "junk_packet_min_size", "junkPacketMinSize") ?? AWG_EXPORT_DEFAULTS.Jmin); + const Jmax = String(pickLc(lc, "Jmax", "junk_packet_max_size", "junkPacketMaxSize") ?? AWG_EXPORT_DEFAULTS.Jmax); + const S1 = String(pickLc(lc, "S1", "init_packet_junk_size", "initPacketJunkSize") ?? AWG_EXPORT_DEFAULTS.S1); + const S2 = String(pickLc(lc, "S2", "response_packet_junk_size", "responsePacketJunkSize") ?? AWG_EXPORT_DEFAULTS.S2); + const S3 = String( + pickLc(lc, "S3", "cookie_reply_packet_junk_size", "cookieReplyPacketJunkSize") ?? AWG_EXPORT_DEFAULTS.S3, + ); + const S4 = String( + pickLc(lc, "S4", "transport_packet_junk_size", "transportPacketJunkSize") ?? AWG_EXPORT_DEFAULTS.S4, + ); + const H1 = String(pickLc(lc, "H1", "init_packet_magic_header", "initPacketMagicHeader") ?? AWG_EXPORT_DEFAULTS.H1); + const H2 = String( + pickLc(lc, "H2", "response_packet_magic_header", "responsePacketMagicHeader") ?? AWG_EXPORT_DEFAULTS.H2, + ); + const H3 = String( + pickLc(lc, "H3", "underload_packet_magic_header", "underloadPacketMagicHeader") ?? AWG_EXPORT_DEFAULTS.H3, + ); + const H4 = String( + pickLc(lc, "H4", "transport_packet_magic_header", "transportPacketMagicHeader") ?? AWG_EXPORT_DEFAULTS.H4, + ); + const I1 = String(pickLc(lc, "I1", "special_junk_1", "specialJunk1") ?? AWG_EXPORT_DEFAULTS.I1); + const I2 = String(pickLc(lc, "I2", "special_junk_2", "specialJunk2") ?? AWG_EXPORT_DEFAULTS.I2); + const I3 = String(pickLc(lc, "I3", "special_junk_3", "specialJunk3") ?? AWG_EXPORT_DEFAULTS.I3); + const I4 = String(pickLc(lc, "I4", "special_junk_4", "specialJunk4") ?? AWG_EXPORT_DEFAULTS.I4); + const I5 = String(pickLc(lc, "I5", "special_junk_5", "specialJunk5") ?? AWG_EXPORT_DEFAULTS.I5); + + return `[Interface] +Address = ${tunnelIp}/32 +DNS = ${dns1}, ${dns2} +PrivateKey = ${priv.trim()} +Jc = ${Jc} +Jmin = ${Jmin} +Jmax = ${Jmax} +S1 = ${S1} +S2 = ${S2} +S3 = ${S3} +S4 = ${S4} +H1 = ${H1} +H2 = ${H2} +H3 = ${H3} +H4 = ${H4} +I1 = ${I1} +I2 = ${I2} +I3 = ${I3} +I4 = ${I4} +I5 = ${I5} +${mtuLine}[Peer] +PublicKey = ${String(serverPub).trim()} +PresharedKey = ${String(psk).trim()} +AllowedIPs = ${peerAllowed} +Endpoint = ${endpointHost}:${port} +PersistentKeepalive = ${keepAlive} +`; + } + + return `[Interface] +Address = ${tunnelIp}/32 +DNS = ${dns1}, ${dns2} +PrivateKey = ${priv.trim()} +${mtuLine}[Peer] +PublicKey = ${String(serverPub).trim()} +PresharedKey = ${String(psk).trim()} +AllowedIPs = ${peerAllowed} +Endpoint = ${endpointHost}:${port} +PersistentKeepalive = ${keepAlive} +`; +} + async function disableClient(rt, clientId, ts) { await rt.backupRemoteFiles(); const { conf, clients } = await rt.loadState(); @@ -1076,6 +1313,7 @@ app.get("/api/clients", requireAuth, async (req, res) => { Boolean(warpMeta.supported && warpMeta.installed) && activeInConf && peerUsesWarp(peer, warpSelected), + exportAvailable: clientHasExportableLastConfig(c), }; }); const warpOut = @@ -1106,6 +1344,47 @@ app.get("/api/clients", requireAuth, async (req, res) => { } }); +app.post("/api/clients/export-config", requireAuth, async (req, res) => { + const rt = runtimeForRequest(req); + const clientId = req.body?.clientId; + if (!clientId || typeof clientId !== "string") { + res.status(400).json({ error: "Укажите clientId" }); + return; + } + try { + const { conf, clients } = await rt.loadState(); + const row = clients.find((c) => c.clientId === clientId); + if (!row) { + res.status(404).json({ error: "Клиент не найден в clientsTable" }); + return; + } + const lc = parseLastConfigFromClientRow(row); + if (!lc) { + res.status(404).json({ + error: + "На сервере нет userData.last_config для этого клиента. Полный конфиг хранится в приложении Amnezia на устройстве, где ключ создавали (или синхронизируйте клиентов с сервером из приложения).", + }); + return; + } + const ifaceMap = parseInterfaceKeyValues(conf.head); + let text; + try { + text = await buildClientConfExport(rt, lc, ifaceMap, req, row, conf); + } catch (e) { + res.status(400).json({ error: String(e.message || e) }); + return; + } + const ud = row.userData || {}; + const baseName = safeExportFilenamePart(ud.clientName, clientId.slice(0, 12)); + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.setHeader("Content-Disposition", `attachment; filename="amnezia-${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))) {