diff --git a/README.md b/README.md index 0168e6a..7e27ce7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Amnezia Admin WebUI -Веб-панель на вашем VPS для управления клиентами **AmneziaWG**: вкл/выкл, удаление, переименование, дата отключения. Работает через Docker и `docker exec` в контейнер **Amnezia** (по умолчанию `amnezia-awg2`). +Веб-панель на вашем VPS для управления клиентами **AmneziaWG**: вкл/выкл, удаление, переименование, дата отключения, а также **политика выхода через Cloudflare WARP** по клиентам (без Telegram и без QR — только скрипт установки на хосте и блок в вебе). Работает через Docker и `docker exec` в контейнер **Amnezia** (по умолчанию `amnezia-awg2`). **Безопасность:** контейнер с монтированием `docker.sock` эквивалентен root на хосте — используйте сложный пароль и по возможности ограничьте доступ по IP или TLS. @@ -48,7 +48,11 @@ cd /opt/amnezia-admin && chmod +x scripts/install.sh && sudo SKIP_DOWNLOAD=1 bas | `DATA_DIR` | `/opt/amnezia-admin-data` | Том с `password.hash` и сессией | | `HOST_PORT` | `8080` | Порт HTTP панели на хосте | | `AWG_CONTAINER` | `amnezia-awg2` | Имя контейнера Amnezia WG | -| `AWG_PROFILES` | _(нет)_ | JSON-массив профилей: несколько контейнеров/путей (см. ниже). Если задан — переключатель «Инстанс» в вебе | +| `AWG_PROFILES` | _(нет)_ | JSON-массив профилей: несколько контейнеров/путей (см. ниже). Если задан — переключатель «Инстанс» в вебе. В каждом объекте можно задать `warpDir`, `warpConf`, `warpClientsList`, `startScript` — см. раздел **Cloudflare WARP** | +| `WARP_DIR` | `/opt/warp` | В контейнере AWG: каталог для `warp.conf` и `clients.list` | +| `WARP_CONF_PATH` | `{WARP_DIR}/warp.conf` | Нестандартный путь к конфигу WARP | +| `WARP_CLIENTS_LIST` | `{WARP_DIR}/clients.list` | Список строк `10.8.x.x/32` — кому маршрутизировать трафик через интерфейс `warp` | +| `AMNEZIA_START_SCRIPT` | `/opt/amnezia/start.sh` | Куда встраивается блок автоподъёма WARP после перезапуска (маркеры совместимы с прежним `warp-manager`) | | `SCHEDULE_DISCONNECT_MS` | `60000` | Как часто планировщик проверяет отложенное отключение из туннеля (мс) | | `TZ` | _(часто UTC в Docker)_ | Пояс строки «Сервер» в панели (IANA, например `Europe/Berlin`). Без `TZ` берётся из образа (часто UTC) — тогда от браузера будет видна разница часов | | `TIME_SYNC_SSH_HOST` | `172.17.0.1` | Хост для SSH root при синхронизации времени из панели (часто шлюз Docker к хосту) | @@ -72,6 +76,25 @@ curl -fsSL https://raw.githubusercontent.com/andrey271192/Amnezia_web/main/scrip Для **Legacy** часто используется обычный `wg`, для новой AmneziaWG — `awg`; подставьте свои `container`, `confPath`, `clientsPath`, `iface`, `pskPath` при необходимости. +### Cloudflare WARP (только AmneziaWG в Docker) + +Установка **без Telegram и без QR**: скрипт на хосте регистрирует туннель через [wgcf](https://github.com/ViRb3/wgcf), собирает `warp.conf` внутри контейнера (`/opt/warp` по умолчанию). Дальше в веб-панели выбираете, какие клиенты выходят в интернет через интерфейс `warp`. + +На **хосте VPS** (root), из каталога с репозиторием: + +```bash +cd /opt/amnezia-admin +chmod +x scripts/warp-amnezia.sh +# при необходимости: AWG_CONTAINER=имя_контейнера +./scripts/warp-amnezia.sh install +``` + +Подкоманды: `install`, `start`, `stop`, `status`, `rekey`. Учёт wgcf хранится в `/root/wgcf-account.toml` на хосте. + +В панели: раздел **Cloudflare WARP** — отметить клиентов (только IPv4 вида `10.8.x.x/32`), **Применить маршрутизацию** (контейнер AWG перезапускается). Маркеры в `start.sh` (`# --- WARP-MANAGER BEGIN ---`) совместимы с прежним `warp-manager`, если вы уже использовали его. + +Необязательно передайте в контейнер **amnezia-admin** переменные `WARP_DIR`, `WARP_CONF_PATH`, `WARP_CLIENTS_LIST`, `AMNEZIA_START_SCRIPT` через установщик — см. таблицу выше. + После установки: **админ-панель** `http://IP:8080` (или ваш `HOST_PORT`), **страница с поддержкой проекта** `http://IP/` на порту лендинга (по умолчанию **80**). Кнопка на лендинге ведёт на админку с тем же `HOST_PORT`. Футер с ссылками (**Amnezia Admin WebUI**, Boosty, Ozon СБП, Telegram) в админке находится **внизу страницы** — прокрутите ниже таблицы. diff --git a/public/app.js b/public/app.js index 3ed635e..578a576 100644 --- a/public/app.js +++ b/public/app.js @@ -15,6 +15,12 @@ const statusEl = document.querySelector("#status"); const peerCountEl = document.querySelector("#peer-count"); const wgShowEl = document.querySelector("#wg-show"); +const warpPanel = document.querySelector("#warp-panel"); +const warpStatusLine = document.querySelector("#warp-status-line"); +const warpActionsEl = document.querySelector("#warp-actions"); +const warpClientListEl = document.querySelector("#warp-client-list"); +const warpWgShowEl = document.querySelector("#warp-wg-show"); + const protoSwitch = document.querySelector("#proto-switch"); const protoSelect = document.querySelector("#proto-select"); const protoLabel = document.querySelector("#proto-label"); @@ -514,7 +520,11 @@ function renderRows(clients) { const stTd = document.createElement("td"); const badge = document.createElement("span"); badge.className = `badge ${c.activeInConf ? "on" : "off"}`; - badge.textContent = c.activeInConf ? "В туннеле" : "Выключен"; + if (c.activeInConf && c.warpEnabled) { + badge.textContent = "В туннеле · WARP"; + } else { + badge.textContent = c.activeInConf ? "В туннеле" : "Выключен"; + } stTd.appendChild(badge); const offTd = document.createElement("td"); @@ -554,6 +564,137 @@ function btn(label, cls, onClick) { return b; } +/** Для политики WARP на сервере нужны IPv4 вида 10.8.x.x/32 */ +function parseIpv4Cidrs(allowedIps) { + if (!allowedIps) return []; + return String(allowedIps) + .split(",") + .map((x) => x.trim()) + .filter((x) => /^(\d{1,3}\.){3}\d{1,3}\/\d{1,3}$/.test(x)); +} + +/** @param {{ warp?: Record; clients: Record[] }} data */ +function renderWarpPanel(data) { + if (!warpPanel || !warpStatusLine || !warpActionsEl || !warpClientListEl || !warpWgShowEl) return; + const w = data.warp; + if (!w || w.supported === false) { + warpPanel.hidden = true; + return; + } + warpPanel.hidden = false; + warpActionsEl.innerHTML = ""; + warpClientListEl.innerHTML = ""; + warpWgShowEl.textContent = typeof w.wgShowWarp === "string" ? w.wgShowWarp : ""; + + if (!w.installed) { + warpStatusLine.textContent = "Не установлен"; + const hint = document.createElement("p"); + hint.className = "muted warp-muted"; + hint.innerHTML = + "Один раз на хосте (root): bash scripts/warp-amnezia.sh install — из каталога клона репозитория на VPS. Если контейнер не угадан автоматически: bash scripts/warp-amnezia.sh install amnezia-awg2."; + warpActionsEl.appendChild(hint); + return; + } + + const parts = []; + parts.push(w.running ? "Интерфейс warp поднят" : "Интерфейс warp опущен"); + if (w.exitIp) parts.push(`выход ${w.exitIp}`); + warpStatusLine.textContent = parts.join(" · "); + + const selection = new Set((w.selectedAllowedIps || []).map(String)); + + function redrawChecks() { + warpClientListEl.innerHTML = ""; + const frag = document.createDocumentFragment(); + let any = false; + for (const c of data.clients) { + if (!c.activeInConf) continue; + const ips = parseIpv4Cidrs(c.allowedIps); + if (!ips.length) continue; + any = true; + const ip = ips[0]; + const label = document.createElement("label"); + label.className = "warp-check-row"; + const cb = document.createElement("input"); + cb.type = "checkbox"; + cb.checked = selection.has(ip); + cb.addEventListener("change", () => { + if (cb.checked) selection.add(ip); + else selection.delete(ip); + }); + const span = document.createElement("span"); + span.textContent = `${c.name} · ${ip}`; + label.append(cb, span); + frag.appendChild(label); + } + warpClientListEl.appendChild(frag); + if (!any) { + const p = document.createElement("p"); + p.className = "muted warp-muted"; + p.textContent = + "Нет активных клиентов с IPv4 AllowedIPs (/32) — WARP-политика в вебе работает только для таких адресов."; + warpClientListEl.appendChild(p); + } + } + + redrawChecks(); + + warpActionsEl.appendChild( + btn("Поднять WARP", "btn small primary", async () => { + try { + setStatus("Поднимаю WARP…", false); + await api("/api/warp/start", { method: "POST", body: JSON.stringify({}) }); + setStatus("Готово.", false); + await loadClients(); + } catch (e) { + setStatus(String(e.message || e), true); + } + }), + ); + warpActionsEl.appendChild( + btn("Остановить WARP", "btn small ghost", async () => { + try { + setStatus("Останавливаю WARP…", false); + await api("/api/warp/stop", { method: "POST", body: JSON.stringify({}) }); + setStatus("Готово.", false); + await loadClients(); + } catch (e) { + setStatus(String(e.message || e), true); + } + }), + ); + warpActionsEl.appendChild( + btn("Все в WARP", "btn small ghost", () => { + for (const c of data.clients) { + if (!c.activeInConf) continue; + parseIpv4Cidrs(c.allowedIps).forEach((ip) => selection.add(ip)); + } + redrawChecks(); + }), + ); + warpActionsEl.appendChild( + btn("Никого", "btn small ghost", () => { + selection.clear(); + redrawChecks(); + }), + ); + warpActionsEl.appendChild( + btn("Применить маршрутизацию", "btn small primary", async () => { + try { + setStatus("Сохраняю WARP и перезапускаю контейнер AWG…", false); + await api("/api/warp/routing", { + method: "POST", + body: JSON.stringify({ selectedAllowedIps: [...selection] }), + }); + setStatus("Готово.", false); + await loadClients(); + } catch (e) { + setStatus(String(e.message || e), true); + } + }), + ); +} + function escapeHtml(s) { return String(s) .replace(/&/g, "&") @@ -609,6 +750,7 @@ async function loadClients() { const pref = data.profileLabel ? `${data.profileLabel} · ` : ""; peerCountEl.textContent = `${pref}${data.clients.length} в таблице · ${data.peerCount} peer`; wgShowEl.textContent = data.wgShow || ""; + renderWarpPanel(data); renderRows(data.clients); setStatus("", false); void refreshServerClock(); @@ -624,6 +766,7 @@ async function loadClients() { rowsEl.innerHTML = ""; wgShowEl.textContent = ""; peerCountEl.textContent = ""; + if (warpPanel) warpPanel.hidden = true; } } diff --git a/public/index.html b/public/index.html index eeef47f..8ceb67f 100644 --- a/public/index.html +++ b/public/index.html @@ -88,6 +88,24 @@

+ +

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

diff --git a/public/styles.css b/public/styles.css index 031a9ef..920114d 100644 --- a/public/styles.css +++ b/public/styles.css @@ -663,3 +663,59 @@ tr:last-child td { color: var(--muted); max-width: 62rem; } + +/* --- Cloudflare WARP --- */ +.warp-panel .warp-intro { + font-size: 0.88rem; + line-height: 1.55; + margin: 0 0 1rem; +} + +.warp-panel .warp-intro code.inline { + font-family: "JetBrains Mono", ui-monospace, monospace; + font-size: 0.78rem; + padding: 0.12rem 0.38rem; + border-radius: 6px; + background: rgba(0, 0, 0, 0.35); + border: 1px solid var(--line); +} + +.warp-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + margin-bottom: 1rem; +} + +.warp-client-list { + display: flex; + flex-direction: column; + gap: 0.4rem; + margin-bottom: 0.85rem; +} + +.warp-check-row { + display: flex; + align-items: center; + gap: 0.55rem; + font-size: 0.88rem; + padding: 0.35rem 0.5rem; + border-radius: 10px; + border: 1px solid var(--line); + background: rgba(0, 0, 0, 0.2); +} + +.warp-check-row input { + flex-shrink: 0; +} + +.warp-muted { + font-size: 0.78rem; + color: var(--muted); +} + +.warp-raw pre { + max-height: 220px; + overflow: auto; +} diff --git a/scripts/install.sh b/scripts/install.sh index 4aeb31f..b7b7a1d 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -113,6 +113,12 @@ if [[ -n "${TZ:-}" ]]; then RUN_ENV+=( -e "TZ=${TZ}" ) fi +for __warp_var in WARP_DIR WARP_CONF_PATH WARP_CLIENTS_LIST AMNEZIA_START_SCRIPT; do + if [[ -n "${!__warp_var:-}" ]]; then + RUN_ENV+=( -e "${__warp_var}=${!__warp_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/scripts/warp-amnezia.sh b/scripts/warp-amnezia.sh new file mode 100755 index 0000000..c76b02b --- /dev/null +++ b/scripts/warp-amnezia.sh @@ -0,0 +1,252 @@ +#!/usr/bin/env bash +# Cloudflare WARP внутри контейнера AmneziaWG (wgcf → warp.conf → wg-quick). +# Запускать на хосте VPS от root. Без Telegram и без QR — только установка и обслуживание туннеля WARP. +# После install управление «кто выходит через WARP» — в веб-панели Amnezia Admin (раздел WARP). +set -euo pipefail + +WGCF_VERSION="${WGCF_VERSION:-2.2.30}" +WGCF_BIN="${WGCF_BIN:-/root/wgcf}" +WGCF_ACCOUNT="${WGCF_ACCOUNT:-/root/wgcf-account.toml}" +WGCF_PROFILE="${WGCF_PROFILE:-/root/wgcf-profile.conf}" + +usage() { + echo "Использование: $0 {install|start|stop|status|rekey} [имя_контейнера]" + echo "Переменные: AWG_CONTAINER, WARP_DIR (по умолчанию /opt/warp)" + exit 1 +} + +need_root() { + if [[ "${EUID:-0}" -ne 0 ]]; then + echo "Запустите от root." + exit 1 + fi +} + +pick_container() { + local c="${2:-${AWG_CONTAINER:-}}" + if [[ -n "$c" ]] && docker exec "$c" true 2>/dev/null; then + CONTAINER="$c" + return 0 + fi + local -a found=() + while IFS= read -r n; do found+=("$n"); done < <(docker ps --format '{{.Names}}' | grep -E '^amnezia-awg2$|^amnezia-awg$' || true) + if [[ ${#found[@]} -eq 1 ]]; then + CONTAINER="${found[0]}" + return 0 + fi + if [[ ${#found[@]} -gt 1 ]]; then + echo "Несколько контейнеров: ${found[*]}. Укажите вторым аргументом или AWG_CONTAINER=" + exit 1 + fi + echo "Не найден контейнер amnezia-awg / amnezia-awg2." + exit 1 +} + +load_paths() { + AWG_WARP_DIR="${WARP_DIR:-/opt/warp}" + AWG_WARP_CONF="${AWG_WARP_DIR}/warp.conf" + AWG_VPN_CONF="" + if docker exec "$CONTAINER" test -f /opt/amnezia/awg/awg0.conf 2>/dev/null; then + AWG_VPN_CONF="/opt/amnezia/awg/awg0.conf" + elif docker exec "$CONTAINER" test -f /opt/amnezia/awg/wg0.conf 2>/dev/null; then + AWG_VPN_CONF="/opt/amnezia/awg/wg0.conf" + else + for f in /opt/amnezia/awg/wg0.conf /opt/amnezia/awg/awg0.conf /etc/wireguard/wg0.conf; do + if docker exec "$CONTAINER" test -f "$f" 2>/dev/null; then + AWG_VPN_CONF="$f" + break + fi + done + fi + [[ -n "$AWG_VPN_CONF" ]] || { + echo "Не найден конфиг WireGuard/AWG в контейнере." + exit 1 + } +} + +install_wgcf() { + [[ -x "$WGCF_BIN" ]] && return 0 + local arch wa + arch="$(uname -m)" + case "$arch" in + x86_64) wa="amd64" ;; + aarch64 | arm64) wa="arm64" ;; + armv7l) wa="armv7" ;; + *) echo "Архитектура не поддерживается: $arch"; exit 1 ;; + esac + wget -q -O "$WGCF_BIN" "https://github.com/ViRb3/wgcf/releases/download/v${WGCF_VERSION}/wgcf_${WGCF_VERSION}_linux_${wa}" + chmod +x "$WGCF_BIN" +} + +ensure_account() { + if [[ ! -f "$WGCF_ACCOUNT" ]]; then + echo "Регистрация WARP (wgcf register)…" + (cd /root && yes | "$WGCF_BIN" register >/dev/null 2>&1 || true) + fi + [[ -f "$WGCF_ACCOUNT" ]] || { + echo "Не создан $WGCF_ACCOUNT" + exit 1 + } +} + +generate_profile() { + (cd /root && yes | "$WGCF_BIN" generate >/dev/null 2>&1 || true) + [[ -f "$WGCF_PROFILE" ]] || { + echo "Не создан $WGCF_PROFILE" + exit 1 + } +} + +resolve_endpoint() { + local ep + ep="$(getent ahostsv4 engage.cloudflareclient.com 2>/dev/null | awk 'NR==1{print $1}')" + [[ -n "$ep" ]] || { + echo "Не удалось резолвить engage.cloudflareclient.com" + exit 1 + } + echo "$ep" +} + +build_warp_conf() { + local endpoint_ip="$1" + local pk pub addr + pk="$(awk -F' = ' '/^PrivateKey = /{print $2}' "$WGCF_PROFILE")" + pub="$(awk -F' = ' '/^PublicKey = /{print $2}' "$WGCF_PROFILE")" + addr="$(awk -F' = ' '/^Address = /{print $2}' "$WGCF_PROFILE" | cut -d',' -f1)" + docker exec "$CONTAINER" sh -c "mkdir -p '$AWG_WARP_DIR'" + docker cp "$WGCF_PROFILE" "${CONTAINER}:${AWG_WARP_DIR}/wgcf-profile.conf" 2>/dev/null || true + docker exec "$CONTAINER" sh -c "cat > '$AWG_WARP_CONF' </dev/null 2>&1 || true" + docker exec "$CONTAINER" sh -c "wg-quick up '$AWG_WARP_CONF'" + docker exec "$CONTAINER" ip addr show warp >/dev/null 2>&1 || { + echo "Интерфейс warp не поднялся." + exit 1 + } +} + +warp_down() { + docker exec "$CONTAINER" sh -c "wg-quick down '$AWG_WARP_CONF' 2>/dev/null || true" +} + +is_installed() { + docker exec "$CONTAINER" test -f "$AWG_WARP_CONF" 2>/dev/null +} + +is_running() { + docker exec "$CONTAINER" ip addr show warp >/dev/null 2>&1 +} + +cmd_install() { + echo "Бэкап конфигов в контейнере…" + docker exec "$CONTAINER" sh -c " + ts=\$(date +%Y%m%d-%H%M%S) + cp '$AWG_VPN_CONF' '${AWG_VPN_CONF}.bak-warp-'\$ts 2>/dev/null || true + cp /opt/amnezia/start.sh /opt/amnezia/start.sh.bak-warp-\$ts 2>/dev/null || true + true + " + install_wgcf + ensure_account + generate_profile + local ep + ep="$(resolve_endpoint)" + echo "Endpoint: $ep" + build_warp_conf "$ep" + warp_up + echo "Готово: WARP установлен. Управление клиентами — в веб-панели (раздел WARP)." +} + +cmd_status() { + if is_installed; then + echo "warp.conf: есть ($AWG_WARP_CONF)" + else + echo "warp.conf: нет — выполните: $0 install" + exit 1 + fi + if is_running; then + echo "Интерфейс warp: поднят" + docker exec "$CONTAINER" wg show warp 2>/dev/null || true + echo -n "Внешний IP через WARP: " + docker exec "$CONTAINER" sh -c "curl -fsS --interface warp --connect-timeout 4 https://ifconfig.me 2>/dev/null || echo '?'" + echo + else + echo "Интерфейс warp: опущен ($0 start)" + fi +} + +cmd_rekey() { + is_installed || { + echo "Сначала install." + exit 1 + } + warp_down || true + rm -f "$WGCF_ACCOUNT" + ensure_account + generate_profile + local ep + ep="$(resolve_endpoint)" + build_warp_conf "$ep" + warp_up + echo "Ключ WARP перевыпущен. Заново отметьте клиентов в веб-панели и примените маршрутизацию." +} + +[[ "${1:-}" ]] || usage +need_root +command -v docker >/dev/null || { + echo "Нужен docker в PATH." + exit 1 +} + +CMD="$1" +pick_container "$@" +load_paths + +case "$CMD" in + install) + if is_installed && is_running; then + echo "Уже установлен и работает." + exit 0 + fi + if is_installed && ! is_running; then + echo "Конфиг есть — поднимаю интерфейс…" + warp_up + exit 0 + fi + cmd_install + ;; + start) + is_installed || { + echo "Нет warp.conf — сначала install." + exit 1 + } + is_running && { + echo "Уже работает." + exit 0 + } + warp_up + echo "WARP поднят." + ;; + stop) + is_installed || exit 0 + warp_down + echo "WARP остановлен." + ;; + status) cmd_status ;; + rekey) cmd_rekey ;; + *) usage ;; +esac diff --git a/server.js b/server.js index 5c68053..065bd90 100644 --- a/server.js +++ b/server.js @@ -13,33 +13,53 @@ const SCHEDULER_MS = Number(process.env.SCHEDULE_DISCONNECT_MS || 60_000); function parseProfilesFromEnv() { const raw = process.env.AWG_PROFILES?.trim(); - const fallback = () => [ - { - id: "awg", - label: process.env.AWG_PROFILE_LABEL || "AmneziaWG", - container: process.env.AWG_CONTAINER || "amnezia-awg2", - confPath: process.env.AWG_CONF_PATH || "/opt/amnezia/awg/awg0.conf", - clientsPath: process.env.AWG_CLIENTS_PATH || "/opt/amnezia/awg/clientsTable", - iface: process.env.AWG_IFACE || "awg0", - wgBinary: process.env.AWG_BINARY || "awg", - pskPath: process.env.AWG_PSK_PATH || "/opt/amnezia/awg/wireguard_psk.key", - }, - ]; + const fallback = () => { + const warpDir = (process.env.WARP_DIR || "/opt/warp").replace(/\/+$/, "") || "/opt/warp"; + return [ + { + id: "awg", + label: process.env.AWG_PROFILE_LABEL || "AmneziaWG", + container: process.env.AWG_CONTAINER || "amnezia-awg2", + confPath: process.env.AWG_CONF_PATH || "/opt/amnezia/awg/awg0.conf", + clientsPath: process.env.AWG_CLIENTS_PATH || "/opt/amnezia/awg/clientsTable", + iface: process.env.AWG_IFACE || "awg0", + wgBinary: process.env.AWG_BINARY || "awg", + pskPath: process.env.AWG_PSK_PATH || "/opt/amnezia/awg/wireguard_psk.key", + warpDir, + warpConf: process.env.WARP_CONF_PATH || `${warpDir}/warp.conf`, + warpClientsList: process.env.WARP_CLIENTS_LIST || `${warpDir}/clients.list`, + startScript: process.env.AMNEZIA_START_SCRIPT || "/opt/amnezia/start.sh", + }, + ]; + }; if (!raw) return fallback(); try { const arr = JSON.parse(raw); if (!Array.isArray(arr) || arr.length === 0) return fallback(); return arr - .map((row, i) => ({ - id: String(row.id ?? `p${i}`), - label: String(row.label ?? row.id ?? `Профиль ${i + 1}`), - container: String(row.container ?? ""), - confPath: String(row.confPath ?? row.conf ?? "/opt/amnezia/awg/awg0.conf"), - clientsPath: String(row.clientsPath ?? row.clients ?? "/opt/amnezia/awg/clientsTable"), - iface: String(row.iface ?? row.IFACE ?? "awg0"), - wgBinary: String(row.wgBinary ?? row.binary ?? "awg"), - pskPath: String(row.pskPath ?? row.psk ?? "/opt/amnezia/awg/wireguard_psk.key"), - })) + .map((row, i) => { + const warpDirRaw = row.warpDir ?? "/opt/warp"; + const warpDir = String(warpDirRaw).replace(/\/+$/, "") || "/opt/warp"; + const warpConf = row.warpConf ? String(row.warpConf) : `${warpDir}/warp.conf`; + const warpClientsList = row.warpClientsList + ? String(row.warpClientsList) + : `${warpDir}/clients.list`; + const startScript = String(row.startScript ?? "/opt/amnezia/start.sh"); + return { + id: String(row.id ?? `p${i}`), + label: String(row.label ?? row.id ?? `Профиль ${i + 1}`), + container: String(row.container ?? ""), + confPath: String(row.confPath ?? row.conf ?? "/opt/amnezia/awg/awg0.conf"), + clientsPath: String(row.clientsPath ?? row.clients ?? "/opt/amnezia/awg/clientsTable"), + iface: String(row.iface ?? row.IFACE ?? "awg0"), + wgBinary: String(row.wgBinary ?? row.binary ?? "awg"), + pskPath: String(row.pskPath ?? row.psk ?? "/opt/amnezia/awg/wireguard_psk.key"), + warpDir, + warpConf, + warpClientsList, + startScript, + }; + }) .filter((p) => p.container); } catch { console.warn("AWG_PROFILES: невалидный JSON, используется профиль по умолчанию."); @@ -265,6 +285,296 @@ function execDocker(args, stdin = null) { }); } +/** Запуск `sh -s` внутри контейнера со скриптом по stdin (многострочный shell без экранирования). */ +function dockerExecStdin(container, script) { + return new Promise((resolve, reject) => { + const child = spawn("docker", ["exec", "-i", container, "sh", "-s"], { + stdio: ["pipe", "pipe", "pipe"], + }); + let out = ""; + let err = ""; + child.stdout.on("data", (c) => (out += c)); + child.stderr.on("data", (c) => (err += c)); + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) resolve({ stdout: out, stderr: err }); + else reject(new Error(err.trim() || out.trim() || `exit ${code}`)); + }); + child.stdin.write(script); + child.stdin.end(); + }); +} + +function assertSafeUnixPath(p) { + const s = String(p).trim(); + if (!/^\/[a-zA-Z0-9_/.-]+$/.test(s)) { + throw new Error(`Недопустимый путь: ${p}`); + } + return s; +} + +/** Разрешённые адреса клиента AmneziaWG для правил WARP (обычно одно значение с /32). */ +function assertAllowedIpCidr(token) { + const s = String(token).trim(); + if (!/^(\d{1,3}\.){3}\d{1,3}\/\d{1,3}$/.test(s)) { + throw new Error(`Недопустимый AllowedIPs для WARP: ${token}`); + } + return s; +} + +function peerAllowedIpTokens(peer) { + const raw = peer?.allowedIPs || ""; + return raw + .split(",") + .map((x) => x.trim()) + .filter(Boolean); +} + +async function dockerRestartContainer(container) { + await execDocker(["restart", container]); + for (let i = 0; i < 24; i++) { + try { + await execDocker(["exec", container, "sh", "-c", "true"]); + return; + } catch { + await new Promise((r) => setTimeout(r, 500)); + } + } + throw new Error("Контейнер не ответил после restart"); +} + +async function warpFileExists(rt, remotePath) { + try { + await execDocker(["exec", rt.profile.container, "test", "-f", remotePath]); + return true; + } catch { + return false; + } +} + +async function warpInterfaceUp(rt) { + try { + await execDocker(["exec", rt.profile.container, "ip", "addr", "show", "warp"]); + return true; + } catch { + return false; + } +} + +async function warpLoadSelectedIps(rt) { + try { + const raw = await rt.dockerReadFile(rt.profile.warpClientsList); + const lines = raw.split("\n").map((l) => l.trim()).filter(Boolean); + return lines.map((l) => assertAllowedIpCidr(l)); + } catch { + return []; + } +} + +async function warpSaveSelectedIps(rt, ips) { + const uniq = [...new Set(ips.map((x) => assertAllowedIpCidr(x)))]; + const content = uniq.length ? `${uniq.join("\n")}\n` : ""; + await rt.dockerExec(`mkdir -p '${rt.profile.warpDir}'`); + await rt.dockerWriteFile(rt.profile.warpClientsList, content); +} + +async function warpCleanupRules(rt) { + const sh = `#!/bin/sh +set +e +ip rule | awk '/lookup 100/ {print \$1}' | sed 's/://g' | sort -rn | while read -r pr; do + ip rule del priority "\$pr" 2>/dev/null || true +done +iptables -t nat -S POSTROUTING 2>/dev/null | grep -- '-o warp -j MASQUERADE' | while read -r line; do + rule=$(echo "\$line" | sed 's/^-A /-D /') + iptables -t nat \$rule 2>/dev/null || true +done +ip route flush table 100 2>/dev/null || true +exit 0 +`; + try { + await dockerExecStdin(rt.profile.container, sh); + } catch { + /* ignore */ + } +} + +async function warpApplyRouting(rt, ips) { + await warpCleanupRules(rt); + const list = ips.map((x) => assertAllowedIpCidr(x)); + if (!list.length) return; + await rt.dockerExec( + "ip route add default dev warp table 100 2>/dev/null || ip route replace default dev warp table 100 2>/dev/null || true", + ); + let prio = 100; + for (const ip of list) { + await rt.dockerExec( + `ip rule add from ${ip} table 100 priority ${prio} 2>/dev/null || true && ` + + `(iptables -t nat -C POSTROUTING -s ${ip} -o warp -j MASQUERADE 2>/dev/null || ` + + `iptables -t nat -I POSTROUTING 1 -s ${ip} -o warp -j MASQUERADE)`, + ); + prio += 1; + } +} + +function buildWarpBootBlock(warpConf, ips) { + assertSafeUnixPath(warpConf); + const list = ips.map((x) => assertAllowedIpCidr(x)); + let routing = ""; + if (list.length > 0) { + routing += + "ip route add default dev warp table 100 2>/dev/null || ip route replace default dev warp table 100 2>/dev/null || true\n\n"; + let prio = 100; + for (const ip of list) { + routing += `ip rule add from ${ip} table 100 priority ${prio} 2>/dev/null || true\n`; + routing += `iptables -t nat -C POSTROUTING -s ${ip} -o warp -j MASQUERADE 2>/dev/null || iptables -t nat -I POSTROUTING 1 -s ${ip} -o warp -j MASQUERADE\n`; + prio += 1; + } + routing += "\n"; + } + return ( + "# --- WARP-MANAGER BEGIN ---\n\n" + + `if [ -f '${warpConf}' ]; then\n` + + ` wg-quick up '${warpConf}' || true\n` + + ` sleep 3\n` + + `fi\n\n` + + routing + + "# --- WARP-MANAGER END ---\n" + ); +} + +async function warpPatchStartSh(rt, ips) { + const startScript = rt.profile.startScript; + assertSafeUnixPath(startScript); + const block = buildWarpBootBlock(rt.profile.warpConf, ips); + const delim = `WARPBLK_${crypto.randomBytes(8).toString("hex")}`; + if (block.includes(delim)) { + throw new Error("internal delimiter collision"); + } + const sq = startScript.replace(/'/g, "'\\''"); + const remote = [ + "#!/bin/sh", + "set -e", + `START_SH='${sq}'`, + `BLOCK=$(cat <<'${delim}'`, + block.trimEnd(), + delim, + ")", + 'if grep -qF \'# --- WARP-MANAGER BEGIN ---\' "$START_SH" 2>/dev/null; then', + ' sed -i \'/# --- WARP-MANAGER BEGIN ---/,/# --- WARP-MANAGER END ---/d\' "$START_SH"', + "fi", + 'if grep -qF \'tail -f /dev/null\' "$START_SH"; then', + " tmpfile=$(mktemp)", + " while IFS= read -r line; do", + ' if echo "$line" | grep -qF \'tail -f /dev/null\'; then', + ' printf \'%s\\n\' "$BLOCK"', + " fi", + ' printf \'%s\\n\' "$line"', + ' done < "$START_SH" > "$tmpfile"', + ' mv "$tmpfile" "$START_SH"', + ' chmod +x "$START_SH"', + "else", + ' printf \'\\n%s\\n\' "$BLOCK" >> "$START_SH"', + ' chmod +x "$START_SH"', + "fi", + "", + ].join("\n"); + await dockerExecStdin(rt.profile.container, remote); +} + +async function warpPersistAndRestart(rt, selectedIps) { + await rt.backupRemoteFiles(); + await warpSaveSelectedIps(rt, selectedIps); + await warpApplyRouting(rt, selectedIps); + await warpPatchStartSh(rt, selectedIps); + await dockerRestartContainer(rt.profile.container); +} + +function activePeerAllowedIpSet(conf) { + const set = new Set(); + for (const p of conf.peers) { + for (const t of peerAllowedIpTokens(p)) { + try { + set.add(assertAllowedIpCidr(t)); + } catch { + /* только ipv4 /cidr */ + } + } + } + return set; +} + +async function warpSummaryForRt(rt) { + try { + assertSafeUnixPath(rt.profile.warpConf); + assertSafeUnixPath(rt.profile.warpClientsList); + assertSafeUnixPath(rt.profile.warpDir); + assertSafeUnixPath(rt.profile.startScript); + } catch { + return { supported: false }; + } + let installed = false; + try { + installed = await warpFileExists(rt, rt.profile.warpConf); + } catch { + installed = false; + } + const running = installed ? await warpInterfaceUp(rt) : false; + let exitIp = null; + if (running) { + try { + const out = await rt.dockerExec( + "curl -fsS --interface warp --connect-timeout 4 https://ifconfig.me 2>/dev/null || true", + ); + const t = out.trim(); + exitIp = t || null; + } catch { + exitIp = null; + } + } + let selectedAllowedIps = []; + if (installed) { + try { + selectedAllowedIps = await warpLoadSelectedIps(rt); + } catch { + selectedAllowedIps = []; + } + } + let wgShowWarp = ""; + if (installed && running) { + try { + wgShowWarp = await rt.dockerExec("wg show warp 2>/dev/null || true"); + } catch { + wgShowWarp = ""; + } + } + return { + supported: true, + installed, + running, + exitIp, + wgShowWarp, + selectedAllowedIps, + paths: { + warpConf: rt.profile.warpConf, + clientsList: rt.profile.warpClientsList, + warpDir: rt.profile.warpDir, + startScript: rt.profile.startScript, + }, + }; +} + +function peerUsesWarp(peer, selectedSet) { + if (!peer || !selectedSet.size) return false; + for (const t of peerAllowedIpTokens(peer)) { + try { + if (selectedSet.has(assertAllowedIpCidr(t))) return true; + } catch { + /* ipv6 и др. */ + } + } + return false; +} + function createRuntime(profile) { const container = profile.container; const confPath = profile.confPath; @@ -740,6 +1050,10 @@ app.get("/api/clients", requireAuth, async (req, res) => { } catch { wgShow = ""; } + const warpMeta = await warpSummaryForRt(rt); + const warpSelected = new Set( + warpMeta.supported && warpMeta.installed ? warpMeta.selectedAllowedIps : [], + ); const { conf, clients, peerByKey } = await rt.loadState(); const rows = clients.map((c) => { const id = c.clientId; @@ -759,8 +1073,24 @@ app.get("/api/clients", requireAuth, async (req, res) => { latestHandshake: ud.latestHandshake || null, dataReceived: ud.dataReceived || null, dataSent: ud.dataSent || null, + warpEnabled: + Boolean(warpMeta.supported && warpMeta.installed) && + activeInConf && + peerUsesWarp(peer, warpSelected), }; }); + const warpOut = + warpMeta.supported === false + ? { supported: false } + : { + supported: true, + installed: warpMeta.installed, + running: warpMeta.running, + exitIp: warpMeta.exitIp, + wgShowWarp: warpMeta.wgShowWarp || "", + selectedAllowedIps: warpMeta.selectedAllowedIps, + paths: warpMeta.paths, + }; res.json({ profileId: rt.profile.id, profileLabel: rt.profile.label, @@ -769,6 +1099,7 @@ app.get("/api/clients", requireAuth, async (req, res) => { peerCount: conf.peers.length, clients: rows, wgShow, + warp: warpOut, }); } catch (e) { console.error(e); @@ -776,6 +1107,74 @@ app.get("/api/clients", requireAuth, async (req, res) => { } }); +app.post("/api/warp/start", requireAuth, async (req, res) => { + const rt = runtimeForRequest(req); + if (!(await warpFileExists(rt, rt.profile.warpConf))) { + return res.status(400).json({ + error: + "WARP не установлен (нет warp.conf). Один раз выполните на хосте: scripts/warp-amnezia.sh install — см. README.", + }); + } + try { + await rt.dockerExec(`wg-quick down '${rt.profile.warpConf}' 2>/dev/null || true`); + await rt.dockerExec(`wg-quick up '${rt.profile.warpConf}'`); + res.json({ ok: true }); + } catch (e) { + console.error(e); + res.status(500).json({ error: String(e.message || e) }); + } +}); + +app.post("/api/warp/stop", requireAuth, async (req, res) => { + const rt = runtimeForRequest(req); + if (!(await warpFileExists(rt, rt.profile.warpConf))) { + return res.status(400).json({ error: "WARP не установлен." }); + } + try { + await rt.dockerExec(`wg-quick down '${rt.profile.warpConf}' 2>/dev/null || true`); + res.json({ ok: true }); + } catch (e) { + console.error(e); + res.status(500).json({ error: String(e.message || e) }); + } +}); + +app.post("/api/warp/routing", requireAuth, async (req, res) => { + const rt = runtimeForRequest(req); + if (!(await warpFileExists(rt, rt.profile.warpConf))) { + return res.status(400).json({ + error: + "WARP не установлен. Сначала scripts/warp-amnezia.sh install на хосте VPS (root).", + }); + } + const raw = req.body?.selectedAllowedIps; + if (!Array.isArray(raw)) { + return res.status(400).json({ error: "Ожидается selectedAllowedIps: массив адресов вида 10.8.1.2/32" }); + } + let selected; + try { + selected = raw.map((x) => assertAllowedIpCidr(String(x).trim())); + } catch (e) { + return res.status(400).json({ error: String(e.message || e) }); + } + try { + const { conf } = await rt.loadState(); + const allowed = activePeerAllowedIpSet(conf); + for (const ip of selected) { + if (!allowed.has(ip)) { + return res.status(400).json({ + error: `Адрес ${ip} не совпадает ни с одним активным peer (AllowedIPs) в текущем инстансе.`, + }); + } + } + await warpPersistAndRestart(rt, selected); + res.json({ ok: true }); + } catch (e) { + console.error(e); + res.status(500).json({ error: String(e.message || e) }); + } +}); + app.post("/api/clients/disable", requireAuth, async (req, res) => { const rt = runtimeForRequest(req); const clientId = req.body?.clientId;