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
|
# 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) в админке находится **внизу страницы** — прокрутите ниже таблицы.
|
||||||
|
|||||||
145
public/app.js
145
public/app.js
@@ -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"}`;
|
||||||
badge.textContent = c.activeInConf ? "В туннеле" : "Выключен";
|
if (c.activeInConf && c.warpEnabled) {
|
||||||
|
badge.textContent = "В туннеле · WARP";
|
||||||
|
} else {
|
||||||
|
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, "&")
|
.replace(/&/g, "&")
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
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() {
|
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";
|
||||||
id: "awg",
|
return [
|
||||||
label: process.env.AWG_PROFILE_LABEL || "AmneziaWG",
|
{
|
||||||
container: process.env.AWG_CONTAINER || "amnezia-awg2",
|
id: "awg",
|
||||||
confPath: process.env.AWG_CONF_PATH || "/opt/amnezia/awg/awg0.conf",
|
label: process.env.AWG_PROFILE_LABEL || "AmneziaWG",
|
||||||
clientsPath: process.env.AWG_CLIENTS_PATH || "/opt/amnezia/awg/clientsTable",
|
container: process.env.AWG_CONTAINER || "amnezia-awg2",
|
||||||
iface: process.env.AWG_IFACE || "awg0",
|
confPath: process.env.AWG_CONF_PATH || "/opt/amnezia/awg/awg0.conf",
|
||||||
wgBinary: process.env.AWG_BINARY || "awg",
|
clientsPath: process.env.AWG_CLIENTS_PATH || "/opt/amnezia/awg/clientsTable",
|
||||||
pskPath: process.env.AWG_PSK_PATH || "/opt/amnezia/awg/wireguard_psk.key",
|
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();
|
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) => {
|
||||||
id: String(row.id ?? `p${i}`),
|
const warpDirRaw = row.warpDir ?? "/opt/warp";
|
||||||
label: String(row.label ?? row.id ?? `Профиль ${i + 1}`),
|
const warpDir = String(warpDirRaw).replace(/\/+$/, "") || "/opt/warp";
|
||||||
container: String(row.container ?? ""),
|
const warpConf = row.warpConf ? String(row.warpConf) : `${warpDir}/warp.conf`;
|
||||||
confPath: String(row.confPath ?? row.conf ?? "/opt/amnezia/awg/awg0.conf"),
|
const warpClientsList = row.warpClientsList
|
||||||
clientsPath: String(row.clientsPath ?? row.clients ?? "/opt/amnezia/awg/clientsTable"),
|
? String(row.warpClientsList)
|
||||||
iface: String(row.iface ?? row.IFACE ?? "awg0"),
|
: `${warpDir}/clients.list`;
|
||||||
wgBinary: String(row.wgBinary ?? row.binary ?? "awg"),
|
const startScript = String(row.startScript ?? "/opt/amnezia/start.sh");
|
||||||
pskPath: String(row.pskPath ?? row.psk ?? "/opt/amnezia/awg/wireguard_psk.key"),
|
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);
|
.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;
|
||||||
|
|||||||
Reference in New Issue
Block a user