feat(warp): host install/uninstall via panel SSH + clarify status

POST /api/warp/host-setup runs warp-amnezia.sh on VPS root SSH.
UI buttons when sshpass available; explain missing warp.conf status.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Андрей Бобырев
2026-05-14 20:18:23 +03:00
parent 47842a4047
commit 62f40f92fb
7 changed files with 169 additions and 11 deletions

View File

@@ -62,6 +62,7 @@ cd /opt/amnezia-admin && chmod +x scripts/install.sh && sudo SKIP_DOWNLOAD=1 bas
| `WARP_CONF_PATH` | `{WARP_DIR}/warp.conf` | Нестандартный путь к конфигу WARP | | `WARP_CONF_PATH` | `{WARP_DIR}/warp.conf` | Нестандартный путь к конфигу WARP |
| `WARP_CLIENTS_LIST` | `{WARP_DIR}/clients.list` | Список строк `10.8.x.x/32` — кому маршрутизировать трафик через интерфейс `warp` | | `WARP_CLIENTS_LIST` | `{WARP_DIR}/clients.list` | Список строк `10.8.x.x/32` — кому маршрутизировать трафик через интерфейс `warp` |
| `AMNEZIA_START_SCRIPT` | `/opt/amnezia/start.sh` | Куда встраивается блок автоподъёма WARP после перезапуска (маркеры совместимы с прежним `warp-manager`) | | `AMNEZIA_START_SCRIPT` | `/opt/amnezia/start.sh` | Куда встраивается блок автоподъёма WARP после перезапуска (маркеры совместимы с прежним `warp-manager`) |
| `WARP_SSH_INSTALL_DIR` | `/opt/amnezia-admin` | Каталог на **хосте VPS**, откуда по SSH выполняется `scripts/warp-amnezia.sh` при установке/удалении WARP из панели (`POST /api/warp/host-setup`) |
| `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 к хосту) |
@@ -108,10 +109,12 @@ curl -fsSL https://raw.githubusercontent.com/andrey271192/Amnezia_web/main/scrip
### Cloudflare WARP (необязательно) ### Cloudflare WARP (необязательно)
**Устанавливать не обязательно.** Панель и обычный AmneziaWG работают без WARP. Раздел **Cloudflare WARP** в веб-интерфейсе нужен только если вы хотите, чтобы **выбранные** клиенты (с IPv4 `10.8.x.x/32` в AllowedIPs) выходили в интернет через туннель Cloudflare внутри контейнера AWG. **Устанавливать не обязательно.** Панель и обычный AmneziaWG работают без WARP. Статус **«Не установлен»** значит: в контейнере AWG ещё **нет** `warp.conf` (скрипт на VPS не запускали) — это не ошибка.
Раздел **Cloudflare WARP** в веб-интерфейсе нужен только если вы хотите, чтобы **выбранные** клиенты (с IPv4 `10.8.x.x/32` в AllowedIPs) выходили в интернет через туннель Cloudflare внутри контейнера AWG.
- **Не нужен WARP** — ничего на хосте не запускайте. В панели будет статус **«Не установлен»** — это нормально, блок можно не трогать. - **Не нужен WARP** — ничего на хосте не запускайте. В панели будет статус **«Не установлен»** — это нормально, блок можно не трогать.
- **Нужен WARP** — один раз на хосте VPS (root), из каталога с репозиторием (часто `/opt/amnezia-admin`): - **Нужен WARP** — в панели кнопка **«Установить WARP на VPS»** (пароль root по SSH, как у синхронизации времени хоста; нужны `sshpass` в образе панели и не отключённый `TIME_SYNC_DISABLED`) **или** один раз на хосте VPS (root), из каталога репозитория (часто `/opt/amnezia-admin`):
```bash ```bash
cd /opt/amnezia-admin cd /opt/amnezia-admin
@@ -128,7 +131,7 @@ chmod +x scripts/warp-amnezia.sh
Подкоманды: `install`, `start`, `stop`, `status`, `rekey`, **`uninstall`**. Учёт wgcf на хосте: `/root/wgcf-account.toml` (и бинарник `/root/wgcf`, если скачан). Подкоманды: `install`, `start`, `stop`, `status`, `rekey`, **`uninstall`**. Учёт wgcf на хосте: `/root/wgcf-account.toml` (и бинарник `/root/wgcf`, если скачан).
**Полностью убрать WARP** после установки (интерфейс `warp`, правила маршрутизации/NAT, блок `# --- WARP-MANAGER ---` в `start.sh`, `warp.conf` и список клиентов в каталоге WARP в контейнере; затем перезапуск контейнера AWG): **Полностью убрать WARP** — кнопка **«Удалить WARP с VPS»** в веб-панели (SSH с паролем root, как при установке) **или** на хосте вручную (интерфейс `warp`, правила маршрутизации/NAT, блок `# --- WARP-MANAGER ---` в `start.sh`, `warp.conf` и список клиентов в каталоге WARP в контейнере; затем перезапуск контейнера AWG):
```bash ```bash
cd /opt/amnezia-admin cd /opt/amnezia-admin

View File

@@ -82,6 +82,7 @@
| POST | `/api/clients/disconnect-date` | Даты отключения / расписание | | POST | `/api/clients/disconnect-date` | Даты отключения / расписание |
| GET/POST | `/api/clients/export-config` | Скачать `.conf`; GET — прямая ссылка (сессия); опционально `?token=…` если задан `EXPORT_CONFIG_SECRET` | | GET/POST | `/api/clients/export-config` | Скачать `.conf`; GET — прямая ссылка (сессия); опционально `?token=…` если задан `EXPORT_CONFIG_SECRET` |
| POST | `/api/clients/create-cascade` | `{ "endpointHost", "endpointPort?", "tunnelIp?", "clientName?", "profileId?" }` — новый peer и файл `.conf` | | POST | `/api/clients/create-cascade` | `{ "endpointHost", "endpointPort?", "tunnelIp?", "clientName?", "profileId?" }` — новый peer и файл `.conf` |
| POST | `/api/warp/host-setup` | Установка/удаление WARP на хосте по SSH: `{ "rootPassword", "cmd": "install" \| "uninstall" }` (как синхронизация времени; каталог скрипта — `WARP_SSH_INSTALL_DIR`) |
| POST | `/api/warp/start` | Поднять WARP | | POST | `/api/warp/start` | Поднять WARP |
| POST | `/api/warp/stop` | Остановить WARP | | POST | `/api/warp/stop` | Остановить WARP |
| POST | `/api/warp/routing` | Политика по клиентам | | POST | `/api/warp/routing` | Политика по клиентам |

View File

@@ -1,6 +1,6 @@
{ {
"name": "amnezia-admin", "name": "amnezia-admin",
"version": "1.1.6", "version": "1.1.7",
"private": true, "private": true,
"description": "Web-панель AmneziaWG: несколько инстансов, WARP, каскад/Endpoint, экспорт .conf (Docker)", "description": "Web-панель AmneziaWG: несколько инстансов, WARP, каскад/Endpoint, экспорт .conf (Docker)",
"license": "MIT", "license": "MIT",

View File

@@ -47,6 +47,16 @@ const dtExtra = document.querySelector("#dt-dialog-extra");
const dtHint = document.querySelector("#dt-dialog-hint"); const dtHint = document.querySelector("#dt-dialog-hint");
const dtScheduleTunnel = document.querySelector("#dt-dialog-schedule-tunnel"); const dtScheduleTunnel = document.querySelector("#dt-dialog-schedule-tunnel");
const warpSshDialog = document.querySelector("#warp-ssh-dialog");
const warpSshTitle = document.querySelector("#warp-ssh-title");
const warpSshLead = document.querySelector("#warp-ssh-lead");
const warpSshPw = document.querySelector("#warp-ssh-pw");
const warpSshCancel = document.querySelector("#warp-ssh-cancel");
const warpSshOk = document.querySelector("#warp-ssh-ok");
const warpSshErr = document.querySelector("#warp-ssh-err");
/** @type {"install" | "uninstall" | null} */
let warpSshPendingCmd = null;
/** Какие панели скрыты настройкой сервера (`UI_HIDE_SECTIONS`). */ /** Какие панели скрыты настройкой сервера (`UI_HIDE_SECTIONS`). */
let uiHidden = { users: false, warp: false, cascade: false }; let uiHidden = { users: false, warp: false, cascade: false };
@@ -165,6 +175,61 @@ dtOk.addEventListener("click", async () => {
} }
}); });
function openWarpHostSetup(cmd) {
if (!warpSshDialog || !warpSshTitle || !warpSshLead || !warpSshPw || !warpSshOk || !warpSshErr) return;
warpSshPendingCmd = cmd;
warpSshErr.textContent = "";
warpSshPw.value = "";
if (cmd === "install") {
warpSshTitle.textContent = "Установить Cloudflare WARP";
warpSshLead.textContent =
"На хосте VPS выполнится scripts/warp-amnezia.sh install для контейнера текущего инстанса. SSH так же, как у блока синхронизации времени (TIME_SYNC_SSH_HOST, часто 172.17.0.1). Пароль root не сохраняется.";
warpSshOk.textContent = "Установить";
} else {
warpSshTitle.textContent = "Удалить Cloudflare WARP";
warpSshLead.textContent =
"На хосте выполнится scripts/warp-amnezia.sh uninstall (интерфейс warp, правила, автозапуск в start.sh; контейнер AWG перезапустится).";
warpSshOk.textContent = "Удалить";
}
warpSshDialog.showModal();
}
if (warpSshCancel && warpSshDialog) {
warpSshCancel.addEventListener("click", () => {
warpSshDialog.close();
warpSshPendingCmd = null;
});
}
if (warpSshOk && warpSshDialog && warpSshPw) {
warpSshOk.addEventListener("click", async () => {
const cmd = warpSshPendingCmd;
if (!cmd) return;
const pw = warpSshPw.value;
if (!String(pw).trim()) {
warpSshErr.textContent = "Введите пароль root.";
return;
}
warpSshErr.textContent = "";
try {
setStatus(cmd === "install" ? "Устанавливаю WARP на хосте VPS…" : "Удаляю WARP на хосте VPS…", false);
await api("/api/warp/host-setup", {
method: "POST",
body: JSON.stringify({ rootPassword: pw, cmd }),
});
warpSshDialog.close();
warpSshPendingCmd = null;
warpSshPw.value = "";
setStatus("Готово.", false);
await loadClients();
} catch (e) {
const msg = String(e.message || e);
warpSshErr.textContent = msg;
setStatus(msg, true);
}
});
}
function showLogin() { function showLogin() {
stopClocks(); stopClocks();
loginGate.classList.remove("hidden"); loginGate.classList.remove("hidden");
@@ -661,15 +726,40 @@ function renderWarpPanel(data) {
if (!w.installed) { if (!w.installed) {
warpStatusLine.textContent = "Не установлен"; warpStatusLine.textContent = "Не установлен";
const explain = document.createElement("p");
explain.className = "muted warp-muted";
explain.textContent =
"Так и должно быть, пока на VPS не создан файл warp.conf внутри контейнера AWG. После установки статус сменится; без WARP обычный AmneziaWG уже работает.";
warpActionsEl.appendChild(explain);
if (w.hostSshInstall) {
warpActionsEl.appendChild(
btn("Установить WARP на VPS", "btn small primary", () => openWarpHostSetup("install")),
);
const sshHint = document.createElement("p");
sshHint.className = "muted warp-muted";
sshHint.textContent =
"Кнопка запускает на хосте тот же скрипт, что в README; понадобится пароль root по SSH (не сохраняется). Альтернатива — команда вручную по SSH.";
warpActionsEl.appendChild(sshHint);
}
const hint = document.createElement("p"); const hint = document.createElement("p");
hint.className = "muted warp-muted"; hint.className = "muted warp-muted";
hint.innerHTML = 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>."; "Вручную на хосте (root), из каталога репозитория: <code class=\"inline\">bash scripts/warp-amnezia.sh install</code> или с именем контейнера: <code class=\"inline\">bash scripts/warp-amnezia.sh install amnezia-awg2</code>.";
warpActionsEl.appendChild(hint); warpActionsEl.appendChild(hint);
if (!w.hostSshInstall) {
const noBtn = document.createElement("p");
noBtn.className = "muted warp-muted";
noBtn.textContent =
"Кнопка установки с панели недоступна: нет sshpass в образе панели или включено TIME_SYNC_DISABLED=1 — используйте SSH вручную.";
warpActionsEl.appendChild(noBtn);
}
const skip = document.createElement("p"); const skip = document.createElement("p");
skip.className = "muted warp-muted"; skip.className = "muted warp-muted";
skip.textContent = skip.textContent = "Если выход через Cloudflare не нужен, ничего не нажимайте — VPN уже работает без этого блока.";
"Если Cloudflare WARP не нужен, ничего не выполняйте — AmneziaWG и панель работают без него. Полное удаление: bash scripts/warp-amnezia.sh uninstall (см. README).";
warpActionsEl.appendChild(skip); warpActionsEl.appendChild(skip);
return; return;
} }
@@ -771,6 +861,9 @@ function renderWarpPanel(data) {
} }
}), }),
); );
warpActionsEl.appendChild(
btn("Удалить WARP с VPS", "btn small warn", () => openWarpHostSetup("uninstall")),
);
} }
function escapeHtml(s) { function escapeHtml(s) {

View File

@@ -103,7 +103,7 @@
<strong>Необязательно.</strong> Если достаточно обычного AmneziaWG без выхода части клиентов через Cloudflare — раздел можно не трогать (статус «Не установлен» — норма). <strong>Необязательно.</strong> Если достаточно обычного AmneziaWG без выхода части клиентов через Cloudflare — раздел можно не трогать (статус «Не установлен» — норма).
Если WARP нужен: выход в интернет через Cloudflare для <em>выбранных</em> клиентов (правила маршрутизации и NAT в контейнере). Если WARP нужен: выход в интернет через Cloudflare для <em>выбранных</em> клиентов (правила маршрутизации и NAT в контейнере).
Один раз на хосте VPS под root: в каталоге с репозиторием выполните <code class="inline">bash scripts/warp-amnezia.sh install</code>. Один раз на хосте VPS под root: в каталоге с репозиторием выполните <code class="inline">bash scripts/warp-amnezia.sh install</code>.
Затем отметьте клиентов ниже и нажмите «Применить» (контейнер AWG перезапустится). Убрать WARP полностью: <code class="inline">bash scripts/warp-amnezia.sh uninstall</code> — см. README. Затем отметьте клиентов ниже и нажмите «Применить» (контейнер AWG перезапустится). Установить или удалить WARP с хоста можно кнопками ниже (пароль root по SSH — как при синхронизации времени). Убрать вручную: <code class="inline">bash scripts/warp-amnezia.sh uninstall</code> — см. README.
</p> </p>
<div id="warp-actions" class="warp-actions"></div> <div id="warp-actions" class="warp-actions"></div>
<div id="warp-client-list" class="warp-client-list"></div> <div id="warp-client-list" class="warp-client-list"></div>
@@ -220,6 +220,20 @@
</div> </div>
</dialog> </dialog>
<dialog id="warp-ssh-dialog" class="dt-dialog">
<div class="dt-dialog-inner">
<h3 id="warp-ssh-title">WARP на хосте VPS</h3>
<p class="muted" id="warp-ssh-lead"></p>
<label for="warp-ssh-pw">Пароль root на хосте VPS (не сохраняется)</label>
<input id="warp-ssh-pw" type="password" autocomplete="off" class="clock-sync-input" />
<div class="dt-dialog-actions">
<button type="button" class="btn ghost" id="warp-ssh-cancel">Отмена</button>
<button type="button" class="btn primary" id="warp-ssh-ok">Выполнить</button>
</div>
<p id="warp-ssh-err" class="status err" role="alert"></p>
</div>
</dialog>
<script src="/app.js" type="module"></script> <script src="/app.js" type="module"></script>
</body> </body>
</html> </html>

View File

@@ -113,7 +113,7 @@ PREV_CONTAINER_ENV=""
if docker inspect "${CONTAINER_NAME}" >/dev/null 2>&1; then if docker inspect "${CONTAINER_NAME}" >/dev/null 2>&1; then
PREV_CONTAINER_ENV="$(docker inspect "${CONTAINER_NAME}" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null || true)" PREV_CONTAINER_ENV="$(docker inspect "${CONTAINER_NAME}" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null || true)"
fi fi
for __ui_var in UI_HIDE_SECTIONS UI_HIDE_USERS UI_HIDE_WARP UI_HIDE_CASCADE; do for __ui_var in UI_HIDE_SECTIONS UI_HIDE_USERS UI_HIDE_WARP UI_HIDE_CASCADE WARP_SSH_INSTALL_DIR; do
if [[ -z "${!__ui_var:-}" ]] && [[ -n "${PREV_CONTAINER_ENV}" ]]; then if [[ -z "${!__ui_var:-}" ]] && [[ -n "${PREV_CONTAINER_ENV}" ]]; then
PREV_VAL="" PREV_VAL=""
while IFS= read -r __line; do while IFS= read -r __line; do
@@ -160,7 +160,7 @@ 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 for __warp_var in WARP_DIR WARP_CONF_PATH WARP_CLIENTS_LIST AMNEZIA_START_SCRIPT WARP_SSH_INSTALL_DIR; do
if [[ -n "${!__warp_var:-}" ]]; then if [[ -n "${!__warp_var:-}" ]]; then
RUN_ENV+=( -e "${__warp_var}=${!__warp_var}" ) RUN_ENV+=( -e "${__warp_var}=${!__warp_var}" )
fi fi
@@ -172,7 +172,7 @@ for __export_var in CLIENT_CONFIG_ENDPOINT CLIENT_EXPORT_DNS1 CLIENT_EXPORT_DNS2
fi fi
done done
for __ui_var in UI_HIDE_SECTIONS UI_HIDE_USERS UI_HIDE_WARP UI_HIDE_CASCADE; do for __ui_var in UI_HIDE_SECTIONS UI_HIDE_USERS UI_HIDE_WARP UI_HIDE_CASCADE WARP_SSH_INSTALL_DIR; do
if [[ -n "${!__ui_var:-}" ]]; then if [[ -n "${!__ui_var:-}" ]]; then
RUN_ENV+=( -e "${__ui_var}=${!__ui_var}" ) RUN_ENV+=( -e "${__ui_var}=${!__ui_var}" )
fi fi

View File

@@ -1400,6 +1400,50 @@ app.post("/api/sync-host-time", requireAuth, async (req, res) => {
} }
}); });
app.post("/api/warp/host-setup", requireAuth, async (req, res) => {
if (UI_HIDDEN.warp) {
return res.status(403).json({ error: MSG_UI_WARP_OFF });
}
if (!hostTimeSyncConfigured()) {
return res.status(503).json({
error:
"С панели недоступно: в образе панели нет sshpass или задано TIME_SYNC_DISABLED=1. Запустите на хосте VPS вручную: bash /opt/amnezia-admin/scripts/warp-amnezia.sh install",
});
}
const pw = req.body?.rootPassword;
const cmd = req.body?.cmd;
if (typeof pw !== "string" || !pw.trim()) {
return res.status(400).json({ error: "Укажите пароль root VPS" });
}
if (cmd !== "install" && cmd !== "uninstall") {
return res.status(400).json({ error: "Ожидается cmd: install или uninstall" });
}
const rt = runtimeForRequest(req);
const container = String(rt.profile.container || "").trim();
if (!/^[a-zA-Z0-9_.-]+$/.test(container)) {
return res.status(400).json({ error: "Некорректное имя контейнера в профиле AWG" });
}
let installDir = "/opt/amnezia-admin";
try {
installDir = assertSafeUnixPath(process.env.WARP_SSH_INSTALL_DIR?.trim() || "/opt/amnezia-admin");
} catch {
return res.status(500).json({ error: "Некорректная переменная WARP_SSH_INSTALL_DIR на сервере панели" });
}
const host = process.env.TIME_SYNC_SSH_HOST?.trim() || "172.17.0.1";
const remoteCmd = `bash -lc 'cd ${installDir} && chmod +x scripts/warp-amnezia.sh 2>/dev/null || true && AWG_CONTAINER=${container} ./scripts/warp-amnezia.sh ${cmd}'`;
try {
const out = await sshRootRun(pw.trim(), host, remoteCmd);
res.json({ ok: true, output: out.slice(0, 8000) });
} catch (e) {
console.warn("warp host-setup:", e);
res.status(400).json({
error:
String(e.message || e) ||
"Не удалось выполнить по SSH. Проверьте пароль root, что вход root по паролю разрешён, переменную TIME_SYNC_SSH_HOST и наличие каталога со скриптом на хосте.",
});
}
});
app.post("/api/login", (req, res) => { app.post("/api/login", (req, res) => {
const pw = req.body?.password; const pw = req.body?.password;
if (typeof pw !== "string" || !pw) { if (typeof pw !== "string" || !pw) {
@@ -1520,6 +1564,9 @@ app.get("/api/clients", requireAuth, async (req, res) => {
wgShowWarp: warpMeta.wgShowWarp || "", wgShowWarp: warpMeta.wgShowWarp || "",
selectedAllowedIps: warpMeta.selectedAllowedIps, selectedAllowedIps: warpMeta.selectedAllowedIps,
paths: warpMeta.paths, paths: warpMeta.paths,
hostSshInstall: hostTimeSyncConfigured(),
sshHost: process.env.TIME_SYNC_SSH_HOST?.trim() || "172.17.0.1",
installDir: process.env.WARP_SSH_INSTALL_DIR?.trim() || "/opt/amnezia-admin",
}; };
res.json({ res.json({
profileId: rt.profile.id, profileId: rt.profile.id,