feat: Cloudflare WARP routing for AmneziaWG

Web toggles per IPv4 peer; wgcf install script on host (no Telegram/QR).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Андрей Бобырев
2026-05-14 18:00:39 +03:00
parent 448fe3b360
commit 6f6fb5f9b9
7 changed files with 922 additions and 25 deletions

View File

@@ -1,6 +1,6 @@
# Amnezia Admin WebUI # 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. **Безопасность:** контейнер с монтированием `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` и сессией | | `DATA_DIR` | `/opt/amnezia-admin-data` | Том с `password.hash` и сессией |
| `HOST_PORT` | `8080` | Порт HTTP панели на хосте | | `HOST_PORT` | `8080` | Порт HTTP панели на хосте |
| `AWG_CONTAINER` | `amnezia-awg2` | Имя контейнера Amnezia WG | | `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` | Как часто планировщик проверяет отложенное отключение из туннеля (мс) | | `SCHEDULE_DISCONNECT_MS` | `60000` | Как часто планировщик проверяет отложенное отключение из туннеля (мс) |
| `TZ` | _(часто UTC в Docker)_ | Пояс строки «Сервер» в панели (IANA, например `Europe/Berlin`). Без `TZ` берётся из образа (часто UTC) — тогда от браузера будет видна разница часов | | `TZ` | _(часто UTC в Docker)_ | Пояс строки «Сервер» в панели (IANA, например `Europe/Berlin`). Без `TZ` берётся из образа (часто UTC) — тогда от браузера будет видна разница часов |
| `TIME_SYNC_SSH_HOST` | `172.17.0.1` | Хост для SSH root при синхронизации времени из панели (часто шлюз Docker к хосту) | | `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` при необходимости. Для **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`. После установки: **админ-панель** `http://IP:8080` (или ваш `HOST_PORT`), **страница с поддержкой проекта** `http://IP/` на порту лендинга (по умолчанию **80**). Кнопка на лендинге ведёт на админку с тем же `HOST_PORT`.
Футер с ссылками (**Amnezia Admin WebUI**, Boosty, Ozon СБП, Telegram) в админке находится **внизу страницы** — прокрутите ниже таблицы. Футер с ссылками (**Amnezia Admin WebUI**, Boosty, Ozon СБП, Telegram) в админке находится **внизу страницы** — прокрутите ниже таблицы.

View File

@@ -15,6 +15,12 @@ const statusEl = document.querySelector("#status");
const peerCountEl = document.querySelector("#peer-count"); const peerCountEl = document.querySelector("#peer-count");
const wgShowEl = document.querySelector("#wg-show"); 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 protoSwitch = document.querySelector("#proto-switch");
const protoSelect = document.querySelector("#proto-select"); const protoSelect = document.querySelector("#proto-select");
const protoLabel = document.querySelector("#proto-label"); const protoLabel = document.querySelector("#proto-label");
@@ -514,7 +520,11 @@ function renderRows(clients) {
const stTd = document.createElement("td"); const stTd = document.createElement("td");
const badge = document.createElement("span"); const badge = document.createElement("span");
badge.className = `badge ${c.activeInConf ? "on" : "off"}`; badge.className = `badge ${c.activeInConf ? "on" : "off"}`;
if (c.activeInConf && c.warpEnabled) {
badge.textContent = "В туннеле · WARP";
} else {
badge.textContent = c.activeInConf ? "В туннеле" : "Выключен"; badge.textContent = c.activeInConf ? "В туннеле" : "Выключен";
}
stTd.appendChild(badge); stTd.appendChild(badge);
const offTd = document.createElement("td"); const offTd = document.createElement("td");
@@ -554,6 +564,137 @@ function btn(label, cls, onClick) {
return b; 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<string, unknown>; clients: Record<string, unknown>[] }} 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): <code class=\"inline\">bash scripts/warp-amnezia.sh install</code> — из каталога клона репозитория на VPS. Если контейнер не угадан автоматически: <code class=\"inline\">bash scripts/warp-amnezia.sh install amnezia-awg2</code>.";
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) { function escapeHtml(s) {
return String(s) return String(s)
.replace(/&/g, "&amp;") .replace(/&/g, "&amp;")
@@ -609,6 +750,7 @@ async function loadClients() {
const pref = data.profileLabel ? `${data.profileLabel} · ` : ""; const pref = data.profileLabel ? `${data.profileLabel} · ` : "";
peerCountEl.textContent = `${pref}${data.clients.length} в таблице · ${data.peerCount} peer`; peerCountEl.textContent = `${pref}${data.clients.length} в таблице · ${data.peerCount} peer`;
wgShowEl.textContent = data.wgShow || ""; wgShowEl.textContent = data.wgShow || "";
renderWarpPanel(data);
renderRows(data.clients); renderRows(data.clients);
setStatus("", false); setStatus("", false);
void refreshServerClock(); void refreshServerClock();
@@ -624,6 +766,7 @@ async function loadClients() {
rowsEl.innerHTML = ""; rowsEl.innerHTML = "";
wgShowEl.textContent = ""; wgShowEl.textContent = "";
peerCountEl.textContent = ""; peerCountEl.textContent = "";
if (warpPanel) warpPanel.hidden = true;
} }
} }

View File

@@ -88,6 +88,24 @@
<p id="status" class="status" role="status"></p> <p id="status" class="status" role="status"></p>
<section class="panel warp-panel" id="warp-panel" hidden>
<div class="panel-head">
<h2>Cloudflare WARP</h2>
<span class="muted" id="warp-status-line"></span>
</div>
<p class="muted warp-intro">
Выход в интернет через Cloudflare WARP для выбранных клиентов AmneziaWG (правила в контейнере: таблица маршрутизации и NAT).
Установка без Telegram и QR — один раз на хосте: <code class="inline">bash scripts/warp-amnezia.sh install</code>
(из каталога репозитория, под root). Дальше — галочки ниже и «Применить» (контейнер перезапустится).
</p>
<div id="warp-actions" class="warp-actions"></div>
<div id="warp-client-list" class="warp-client-list"></div>
<details class="raw warp-raw">
<summary>Вывод wg show warp</summary>
<pre id="warp-wg-show"></pre>
</details>
</section>
<section class="panel"> <section class="panel">
<div class="panel-head"> <div class="panel-head">
<h2>Пользователи</h2> <h2>Пользователи</h2>

View File

@@ -663,3 +663,59 @@ tr:last-child td {
color: var(--muted); color: var(--muted);
max-width: 62rem; 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;
}

View File

@@ -113,6 +113,12 @@ if [[ -n "${TZ:-}" ]]; then
RUN_ENV+=( -e "TZ=${TZ}" ) RUN_ENV+=( -e "TZ=${TZ}" )
fi 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 if [[ -n "${BOOT_PW}" ]]; then
RUN_ENV+=( -e "ADMIN_PASSWORD=${BOOT_PW}" ) RUN_ENV+=( -e "ADMIN_PASSWORD=${BOOT_PW}" )
elif [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "1" ]] || [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "true" ]]; then elif [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "1" ]] || [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "true" ]]; then

252
scripts/warp-amnezia.sh Executable file
View File

@@ -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' <<WARPEOF
[Interface]
PrivateKey = ${pk}
Address = ${addr}
MTU = 1280
Table = off
[Peer]
PublicKey = ${pub}
AllowedIPs = 0.0.0.0/0
Endpoint = ${endpoint_ip}:2408
PersistentKeepalive = 25
WARPEOF
chmod 600 '$AWG_WARP_CONF'"
}
warp_up() {
docker exec "$CONTAINER" sh -c "wg-quick down '$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

405
server.js
View File

@@ -13,7 +13,9 @@ const SCHEDULER_MS = Number(process.env.SCHEDULE_DISCONNECT_MS || 60_000);
function parseProfilesFromEnv() { function parseProfilesFromEnv() {
const raw = process.env.AWG_PROFILES?.trim(); const raw = process.env.AWG_PROFILES?.trim();
const fallback = () => [ const fallback = () => {
const warpDir = (process.env.WARP_DIR || "/opt/warp").replace(/\/+$/, "") || "/opt/warp";
return [
{ {
id: "awg", id: "awg",
label: process.env.AWG_PROFILE_LABEL || "AmneziaWG", label: process.env.AWG_PROFILE_LABEL || "AmneziaWG",
@@ -23,14 +25,27 @@ function parseProfilesFromEnv() {
iface: process.env.AWG_IFACE || "awg0", iface: process.env.AWG_IFACE || "awg0",
wgBinary: process.env.AWG_BINARY || "awg", wgBinary: process.env.AWG_BINARY || "awg",
pskPath: process.env.AWG_PSK_PATH || "/opt/amnezia/awg/wireguard_psk.key", 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(); if (!raw) return fallback();
try { try {
const arr = JSON.parse(raw); const arr = JSON.parse(raw);
if (!Array.isArray(arr) || arr.length === 0) return fallback(); if (!Array.isArray(arr) || arr.length === 0) return fallback();
return arr return arr
.map((row, i) => ({ .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}`), id: String(row.id ?? `p${i}`),
label: String(row.label ?? row.id ?? `Профиль ${i + 1}`), label: String(row.label ?? row.id ?? `Профиль ${i + 1}`),
container: String(row.container ?? ""), container: String(row.container ?? ""),
@@ -39,7 +54,12 @@ function parseProfilesFromEnv() {
iface: String(row.iface ?? row.IFACE ?? "awg0"), iface: String(row.iface ?? row.IFACE ?? "awg0"),
wgBinary: String(row.wgBinary ?? row.binary ?? "awg"), wgBinary: String(row.wgBinary ?? row.binary ?? "awg"),
pskPath: String(row.pskPath ?? row.psk ?? "/opt/amnezia/awg/wireguard_psk.key"), pskPath: String(row.pskPath ?? row.psk ?? "/opt/amnezia/awg/wireguard_psk.key"),
})) warpDir,
warpConf,
warpClientsList,
startScript,
};
})
.filter((p) => p.container); .filter((p) => p.container);
} catch { } catch {
console.warn("AWG_PROFILES: невалидный JSON, используется профиль по умолчанию."); 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) { function createRuntime(profile) {
const container = profile.container; const container = profile.container;
const confPath = profile.confPath; const confPath = profile.confPath;
@@ -740,6 +1050,10 @@ app.get("/api/clients", requireAuth, async (req, res) => {
} catch { } catch {
wgShow = ""; wgShow = "";
} }
const warpMeta = await warpSummaryForRt(rt);
const warpSelected = new Set(
warpMeta.supported && warpMeta.installed ? warpMeta.selectedAllowedIps : [],
);
const { conf, clients, peerByKey } = await rt.loadState(); const { conf, clients, peerByKey } = await rt.loadState();
const rows = clients.map((c) => { const rows = clients.map((c) => {
const id = c.clientId; const id = c.clientId;
@@ -759,8 +1073,24 @@ app.get("/api/clients", requireAuth, async (req, res) => {
latestHandshake: ud.latestHandshake || null, latestHandshake: ud.latestHandshake || null,
dataReceived: ud.dataReceived || null, dataReceived: ud.dataReceived || null,
dataSent: ud.dataSent || 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({ res.json({
profileId: rt.profile.id, profileId: rt.profile.id,
profileLabel: rt.profile.label, profileLabel: rt.profile.label,
@@ -769,6 +1099,7 @@ app.get("/api/clients", requireAuth, async (req, res) => {
peerCount: conf.peers.length, peerCount: conf.peers.length,
clients: rows, clients: rows,
wgShow, wgShow,
warp: warpOut,
}); });
} catch (e) { } catch (e) {
console.error(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) => { app.post("/api/clients/disable", requireAuth, async (req, res) => {
const rt = runtimeForRequest(req); const rt = runtimeForRequest(req);
const clientId = req.body?.clientId; const clientId = req.body?.clientId;