diff --git a/public/app.js b/public/app.js index b0c578d..a76ed40 100644 --- a/public/app.js +++ b/public/app.js @@ -651,6 +651,11 @@ if (cascadeForm) { cascadeForm.addEventListener("submit", (ev) => void downloadCascadeConf(ev)); } +const directForm = document.querySelector("#direct-form"); +if (directForm) { + directForm.addEventListener("submit", (ev) => void downloadDirectConf(ev)); +} + protoSelect.addEventListener("change", async () => { try { setStatus("Смена инстанса…", false); @@ -1349,6 +1354,58 @@ async function downloadCascadeConf(ev) { } } +async function downloadDirectConf(ev) { + ev.preventDefault(); + const nameEl = document.querySelector("#direct-name"); + const tunnelEl = document.querySelector("#direct-tunnel-ip"); + const body = {}; + const nm = nameEl?.value.trim(); + if (nm) body.clientName = nm; + const tip = tunnelEl?.value.trim(); + if (tip) body.tunnelIp = tip; + const pid = currentProfileIdValue(); + if (pid) body.profileId = pid; + try { + setStatus("Создаю клиента на сервере и собираю .conf…", false); + const res = await fetch("/api/clients/create", { + 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 || "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); + if (nameEl) nameEl.value = ""; + if (tunnelEl) tunnelEl.value = ""; + 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 d1de6e2..1dfe2a9 100644 --- a/public/index.html +++ b/public/index.html @@ -183,6 +183,34 @@ +
+ + + + Новый клиент + Прямое подключение к этому серверу — Endpoint = публичный IP этого VPS + + +
+
+

+ Обычное подключение. Создаёт нового клиента AmneziaWG и сразу скачивает файл + .conf, где Endpoint указывает на этот сервер напрямую. + Импортируйте файл в приложение Amnezia на телефоне или ПК. IP в туннеле подберётся автоматически. +

+
+
+ + + + + + + +
+
+
+
diff --git a/scripts/install.sh b/scripts/install.sh index e3ca1fd..c19cd85 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -145,6 +145,17 @@ if [[ -z "${AWG_PROFILES:-}" ]] && [[ -f "${AWG_PROFILE_SNAPSHOT}" ]]; then fi fi +# Авто-определение единственного контейнера amnezia-awg* (например amnezia-awg2 — +# дефолт Amnezia для AWG 2.0), если AWG_CONTAINER и AWG_PROFILES не заданы вручную. +if [[ -z "${AWG_CONTAINER:-}" ]] && [[ -z "${AWG_PROFILES:-}" ]]; then + __awg_names="$(docker ps --format '{{.Names}}' 2>/dev/null | grep -E '^amnezia-awg' || true)" + __awg_names_count="$(printf '%s\n' "${__awg_names}" | grep -c . || true)" + if [[ "${__awg_names_count}" == "1" ]]; then + AWG_CONTAINER="$(printf '%s\n' "${__awg_names}" | head -n1)" + echo "→ AWG_CONTAINER авто-определён: ${AWG_CONTAINER}" + fi +fi + if [[ -z "${AWG_PROFILES:-}" ]]; then __awg_multi_count="$( docker ps --format '{{.Names}}' 2>/dev/null | awk '/^amnezia-awg/ { c++ } END { print c + 0 }' | tr -d '[:space:]' @@ -213,6 +224,14 @@ for __warp_var in WARP_DIR WARP_CONF_PATH WARP_CLIENTS_LIST AMNEZIA_START_SCRIPT fi done +if [[ -z "${CLIENT_CONFIG_ENDPOINT:-}" ]]; then + __pub_ip="$(hostname -I 2>/dev/null | awk '{print $1}' || true)" + if [[ -n "${__pub_ip}" ]]; then + CLIENT_CONFIG_ENDPOINT="${__pub_ip}" + echo "→ CLIENT_CONFIG_ENDPOINT авто: ${CLIENT_CONFIG_ENDPOINT} (можно переопределить переменной окружения)" + fi +fi + for __export_var in CLIENT_CONFIG_ENDPOINT CLIENT_EXPORT_DNS1 CLIENT_EXPORT_DNS2 EXPORT_CONFIG_SECRET; do if [[ -n "${!__export_var:-}" ]]; then RUN_ENV+=( -e "${__export_var}=${!__export_var}" ) diff --git a/server.js b/server.js index 07ffca0..a803842 100644 --- a/server.js +++ b/server.js @@ -2042,6 +2042,99 @@ app.post("/api/clients/export-config", requireAuth, (req, res) => { * Новый клиент для каскада: генерирует ключи, добавляет peer на сервер, сохраняет last_config, * отдаёт .conf с Endpoint = endpointHost:endpointPort (ваш промежуточный узел). */ +app.post("/api/clients/create", requireAuth, requireProTier, async (req, res) => { + const rt = runtimeFromExportRequest(req); + 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; + const envHost = process.env.CLIENT_CONFIG_ENDPOINT?.trim(); + const hdrHost = + typeof req.headers.host === "string" ? req.headers.host.split(":")[0].trim() : ""; + const endpointHost = envHost || (hdrHost && hdrHost !== "localhost" ? hdrHost : ""); + if (!endpointHost) { + res.status(400).json({ + error: + "Не удалось определить публичный адрес сервера. Задайте CLIENT_CONFIG_ENDPOINT (IP/DNS этого VPS) для контейнера панели.", + }); + return; + } + const defaultPort = rt.profile.wgBinary === "awg" ? 55424 : 51820; + const endpointPort = + Number.isFinite(listenPort) && listenPort > 0 ? listenPort : defaultPort; + 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-${baseName}.conf"`); + res.send(text); + } catch (e) { + console.error(e); + res.status(500).json({ error: String(e.message || e) }); + } +}); + app.post("/api/clients/create-cascade", requireAuth, requireProTier, async (req, res) => { if (UI_HIDDEN.cascade) { return res.status(403).json({ error: MSG_UI_CASCADE_OFF });