diff --git a/README.md b/README.md index 145de8f..e06fa3b 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,10 @@ cd /opt/amnezia-admin && chmod +x scripts/install.sh && sudo SKIP_DOWNLOAD=1 bas | `COMMUNITY_PRIVATE_INSTALL_SCRIPT_URL` | `https://raw.githubusercontent.com/andrey271192/amnezia_web-pro/main/scripts/install.sh` | Сырой URL `scripts/install.sh` в **вашем** приватном репозитории PRO | | `COMMUNITY_INSTALL_FETCH_MS` | `60000` | Таймаут HTTP при скачивании установщика из GitHub | | `PRIVATE_INSTALL_SCRIPT_MAX_BYTES` | `2097152` | Максимальный размер скачанного скрипта (байты) | +| `COMMUNITY_DISABLE_DOCKER_CLI_HELPER` | _(не задано)_ | Если `1`, установка из UI снова только «bash внутри панели» (часто конфликт **:8080** с PRO). По умолчанию используется одноразовый контейнер **docker:cli**, который **снимает FREE** и зависший PRO перед `install.sh`. | +| `COMMUNITY_SKIP_REMOVE_FREE_BEFORE_PRIVATE_PRO` | `0` | `1` — не выполнять `docker rm` FREE/лендинга перед install (если знаете, что делаете). | +| `COMMUNITY_PRO_INSTALL_HELPER_IMAGE` | `docker:26-cli` | Образ с бинарём `docker` для фонового контейнера (нужен доступ к демону по сокету). | +| `FREE_PANEL_CONTAINER_FOR_PRO_INSTALL` и др. | см. `server.js` | Имена контейнеров для `docker rm` перед install (по умолчанию `amnezia-admin`, `amnezia-web-landing`, `amnezia-admin-pro`). | Чтобы включить форму установки из панели, задайте в контейнере **`AMNEZIA_EDITION=community`** и **`ALLOW_COMMUNITY_GITHUB_ACTIVATION=1`** (`install.sh`: добавьте `-e ALLOW_COMMUNITY_GITHUB_ACTIVATION=1` или пересоздайте контейнер после правки переменных). URL приватного `install.sh` при необходимости переопределите через **`COMMUNITY_PRIVATE_INSTALL_SCRIPT_URL`**. @@ -90,7 +94,7 @@ curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web/main/scrip ### `Bind for 0.0.0.0:8080 failed: port is already allocated` у `amnezia-admin-pro` -На **8080** уже слушает **FREE**‑панель (**`amnezia-admin`**). Перед запуском образа PRO (**`docker compose`**, **`amnezia-web-pro-deploy`**, свой скрипт): **`docker rm -f amnezia-admin`**, затем снова **`up`**. Два контейнера панели на один и тот же порт не поднять. +На **8080** уже слушает **FREE**‑панель (**`amnezia-admin`**). Пока она работает, второй контейнер панели (PRO) на тот же порт не поднимется. **Кнопка «Установить PRO» из UI по умолчанию** использует одноразовый контейнер **`docker:cli`** (пауза → **`docker rm`** FREE / лендинг / возможный **`amnezia-admin-pro` в Created** → затем ваш **`install.sh`**). Иначе вручную: **`docker rm -f amnezia-admin`**, затем установщик PRO. Отключить авто-снос: **`COMMUNITY_DISABLE_DOCKER_CLI_HELPER=1`**. ### Лендинг: порт 80 занят diff --git a/package.json b/package.json index 6c604c8..441bf2b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "amnezia-admin", - "version": "1.1.9", + "version": "1.2.0", "private": true, "description": "amnezia_web — базовая панель AmneziaWG (FREE: просмотр и удаление клиентов; полная версия — PRO).", "license": "MIT", diff --git a/scripts/install.sh b/scripts/install.sh index a54120a..c7e6dbb 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -125,6 +125,8 @@ fi for __reuse_var in UI_HIDE_SECTIONS UI_HIDE_USERS UI_HIDE_WARP UI_HIDE_CASCADE WARP_SSH_INSTALL_DIR \ ALLOW_COMMUNITY_GITHUB_ACTIVATION COMMUNITY_PRIVATE_INSTALL_SCRIPT_URL COMMUNITY_INSTALL_FETCH_MS PRIVATE_INSTALL_SCRIPT_MAX_BYTES \ COMMUNITY_UPGRADE_URL COMMUNITY_UPGRADE_PITCH AMNEZIA_EDITION \ + COMMUNITY_DISABLE_DOCKER_CLI_HELPER COMMUNITY_SKIP_REMOVE_FREE_BEFORE_PRIVATE_PRO COMMUNITY_PRO_INSTALL_HELPER_IMAGE \ + FREE_PANEL_CONTAINER_FOR_PRO_INSTALL FREE_LANDING_CONTAINER_FOR_PRO_INSTALL STALE_PRO_PANEL_CONTAINER_FOR_PRO_INSTALL \ EXPORT_CONFIG_SECRET CLIENT_CONFIG_ENDPOINT CLIENT_EXPORT_DNS1 CLIENT_EXPORT_DNS2; do if [[ -z "${!__reuse_var:-}" ]] && [[ -n "${PREV_CONTAINER_ENV}" ]]; then PREV_VAL="" @@ -200,7 +202,7 @@ elif [[ -f "${INSTALL_DIR}/.amnezia-panel-edition" ]]; then echo "→ AMNEZIA_EDITION из ${INSTALL_DIR}/.amnezia-panel-edition: ${__PE}" fi fi -for __ce_var in COMMUNITY_UPGRADE_URL COMMUNITY_UPGRADE_PITCH ALLOW_COMMUNITY_GITHUB_ACTIVATION COMMUNITY_PRIVATE_INSTALL_SCRIPT_URL COMMUNITY_INSTALL_FETCH_MS PRIVATE_INSTALL_SCRIPT_MAX_BYTES; do +for __ce_var in COMMUNITY_UPGRADE_URL COMMUNITY_UPGRADE_PITCH ALLOW_COMMUNITY_GITHUB_ACTIVATION COMMUNITY_PRIVATE_INSTALL_SCRIPT_URL COMMUNITY_INSTALL_FETCH_MS PRIVATE_INSTALL_SCRIPT_MAX_BYTES COMMUNITY_DISABLE_DOCKER_CLI_HELPER COMMUNITY_SKIP_REMOVE_FREE_BEFORE_PRIVATE_PRO COMMUNITY_PRO_INSTALL_HELPER_IMAGE FREE_PANEL_CONTAINER_FOR_PRO_INSTALL FREE_LANDING_CONTAINER_FOR_PRO_INSTALL STALE_PRO_PANEL_CONTAINER_FOR_PRO_INSTALL; do if [[ -n "${!__ce_var:-}" ]]; then RUN_ENV+=( -e "${__ce_var}=${!__ce_var}" ) fi diff --git a/server.js b/server.js index 5d80aba..4ca34ba 100644 --- a/server.js +++ b/server.js @@ -47,6 +47,10 @@ const COMMUNITY_PRIVATE_INSTALL_SCRIPT_URL = const PRIVATE_INSTALL_SCRIPT_MAX_BYTES = Number(process.env.PRIVATE_INSTALL_SCRIPT_MAX_BYTES || 2_097_152) || 2_097_152; /** Флаг выполнения одноразового bash install из UI (держим второй параллельный запрос). */ let communityPrivateInstallBusy = false; +const COMMUNITY_SKIP_REMOVE_FREE_BEFORE_PRIVATE_PRO = envTruthy( + process.env.COMMUNITY_SKIP_REMOVE_FREE_BEFORE_PRIVATE_PRO, +); +const COMMUNITY_DISABLE_DOCKER_CLI_HELPER = envTruthy(process.env.COMMUNITY_DISABLE_DOCKER_CLI_HELPER); const COMMUNITY_UPGRADE_URL = process.env.COMMUNITY_UPGRADE_URL?.trim() || "https://boosty.to/andrey27/purchase/3906453?ssource=DIRECT&share=subscription_link"; @@ -367,6 +371,13 @@ function validateGithubBearerToken(tok) { return /^[!-~]+$/.test(s); } +/** Имя контейнера для встраивания в `sh -lc` во внешнем helper (docker rm -f …). */ +function shellSafeDockerContainerName(raw, fallback) { + const s = typeof raw === "string" ? raw.trim() : ""; + if (/^[a-zA-Z][a-zA-Z0-9_.-]{0,127}$/.test(s)) return s; + return fallback; +} + function privateInstallScriptUrlLogged() { try { const u = new URL(COMMUNITY_PRIVATE_INSTALL_SCRIPT_URL); @@ -1474,21 +1485,35 @@ app.post("/api/community/run-private-install", requireAuth, async (req, res) => const header = `\n${"=".repeat(60)}\n${new Date().toISOString()} — старт install из UI (COMMUNITY)\nURL: ${COMMUNITY_PRIVATE_INSTALL_SCRIPT_URL}\n${"=".repeat(60)}\n`; fs.appendFileSync(logAbs, header); - const logStream = fs.createWriteStream(logAbs, { flags: "a" }); + const useDockerCliHelper = !COMMUNITY_DISABLE_DOCKER_CLI_HELPER; + const helperImage = + process.env.COMMUNITY_PRO_INSTALL_HELPER_IMAGE?.trim() || "docker:26-cli"; + const freePanelName = shellSafeDockerContainerName( + process.env.FREE_PANEL_CONTAINER_FOR_PRO_INSTALL, + "amnezia-admin", + ); + const freeLandingName = shellSafeDockerContainerName( + process.env.FREE_LANDING_CONTAINER_FOR_PRO_INSTALL, + "amnezia-web-landing", + ); + const staleProName = shellSafeDockerContainerName( + process.env.STALE_PRO_PANEL_CONTAINER_FOR_PRO_INSTALL, + "amnezia-admin-pro", + ); - const child = spawn("bash", [scriptPath], { - cwd: tmpDir, - env: { - ...process.env, - GITHUB_TOKEN: token, - GH_TOKEN: token, - GIT_TERMINAL_PROMPT: "0", - }, - stdio: ["ignore", "pipe", "pipe"], - }); + if (useDockerCliHelper) { + fs.appendFileSync( + logAbs, + `\n→ Одноразовый установщик: ${helperImage}; FREE=${freePanelName}, лендинг=${freeLandingName}, удаление зависшего PRO=${staleProName}. Отключить слой: COMMUNITY_DISABLE_DOCKER_CLI_HELPER=1\n`, + ); + } else { + fs.appendFileSync( + logAbs, + "\n→ COMMUNITY_DISABLE_DOCKER_CLI_HELPER=1 — install идёт в том же контейнере; FREE не удаляется до скрипта (частый конфликт :8080 с PRO).\n", + ); + } - child.stdout.pipe(logStream, { end: false }); - child.stderr.pipe(logStream, { end: false }); + const logStream = useDockerCliHelper ? null : fs.createWriteStream(logAbs, { flags: "a" }); let finalized = false; const finalize = (code = null, signal = null, errText = "") => { @@ -1503,26 +1528,88 @@ app.post("/api/community/run-private-install", requireAuth, async (req, res) => } catch { // } - logStream.end(() => { + const rmTmp = () => { try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { // } - }); + }; + if (logStream) { + logStream.end(() => rmTmp()); + } else { + rmTmp(); + } }; - child.once("exit", (code, signal) => finalize(code, signal)); - child.once("error", (err) => { - console.warn("community-private-install bash:", err?.message || err); - finalize(null, null, String(err.message || err)); - }); + if (useDockerCliHelper) { + const dataAbs = path.resolve(DATA_DIR); + const prelude = COMMUNITY_SKIP_REMOVE_FREE_BEFORE_PRIVATE_PRO + ? "sleep 2" + : `sleep 3; docker rm -f ${freePanelName} ${freeLandingName} 2>/dev/null || true; docker rm -f ${staleProName} 2>/dev/null || true`; + const sh = ` +set -eu +${prelude} +cd /mnt/stage && exec bash ./install.sh >> /mnt/data/community-install-last.log 2>&1 +`.trim(); + + const dr = spawn( + "docker", + [ + "run", + "--rm", + "--pull=missing", + "-v", + "/var/run/docker.sock:/var/run/docker.sock", + `-v`, + `${tmpDir}:/mnt/stage:ro`, + `-v`, + `${dataAbs}:/mnt/data`, + "-e", + `GITHUB_TOKEN=${token}`, + "-e", + `GH_TOKEN=${token}`, + "-e", + "GIT_TERMINAL_PROMPT=0", + helperImage, + "sh", + "-lc", + sh, + ], + { detached: true, stdio: "ignore" }, + ); + dr.unref(); + dr.once("exit", (code, signal) => finalize(code, signal)); + dr.once("error", (err) => { + console.warn("community-private-install docker-cli helper:", err?.message || err); + finalize(null, null, String(err.message || err)); + }); + } else { + const child = spawn("bash", [scriptPath], { + cwd: tmpDir, + env: { + ...process.env, + GITHUB_TOKEN: token, + GH_TOKEN: token, + GIT_TERMINAL_PROMPT: "0", + }, + stdio: ["ignore", "pipe", "pipe"], + }); + child.stdout.pipe(logStream, { end: false }); + child.stderr.pipe(logStream, { end: false }); + child.once("exit", (code, signal) => finalize(code, signal)); + child.once("error", (err) => { + console.warn("community-private-install bash:", err?.message || err); + finalize(null, null, String(err.message || err)); + }); + } res.status(202).json({ ok: true, queued: true, - message: - "Установка PRO запущена. Панель может прерваться — через 2–5 минут откройте её снова по тому же адресу. Журнал пишется в /data/community-install-last.log в том контейнере, где была FREE-панель (до возможной замены образа).", + message: useDockerCliHelper + ? "Установка PRO: отдельный контейнер сначала снимает FREE (освобождается порт панели) и зависший PRO при наличии, затем выполняется install.sh. Подождите 3–10 мин и откройте адрес снова. Журнал: на VPS каталог данных панели (часто /opt/amnezia-admin-data/community-install-last.log)." + : "Режим без docker-helper: при «port already allocated» снимите FREE вручную. Журнал: /data/community-install-last.log.", logInsideContainer: "community-install-last.log", }); } catch (e) {