mirror of
https://github.com/andrey271192/amnezia_web-PRO.git
synced 2026-09-20 14:42:00 +00:00
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:
27
README.md
27
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) в админке находится **внизу страницы** — прокрутите ниже таблицы.
|
||||
|
||||
145
public/app.js
145
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<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) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,24 @@
|
||||
|
||||
<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">
|
||||
<div class="panel-head">
|
||||
<h2>Пользователи</h2>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
252
scripts/warp-amnezia.sh
Executable file
252
scripts/warp-amnezia.sh
Executable 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
|
||||
443
server.js
443
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;
|
||||
|
||||
Reference in New Issue
Block a user