fix(community): PRO install from UI uses docker-cli helper to free :8080

Run install.sh in a disposable docker:26-cli container after docker rm
FREE/landing/stale PRO; keeps working after API response. Legacy in-panel
bash retained when COMMUNITY_DISABLE_DOCKER_CLI_HELPER=1. Persist related
env vars in install.sh from previous container.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Андрей Бобырев
2026-05-15 01:14:18 +03:00
parent dab8807cc7
commit 902bd99395
4 changed files with 118 additions and 25 deletions

View File

@@ -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 занят

View File

@@ -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",

View File

@@ -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

121
server.js
View File

@@ -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();
}
};
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 запущена. Панель может прерваться — через 25 минут откройте её снова по тому же адресу. Журнал пишется в /data/community-install-last.log в том контейнере, где была FREE-панель (до возможной замены образа).",
message: useDockerCliHelper
? "Установка PRO: отдельный контейнер сначала снимает FREE (освобождается порт панели) и зависший PRO при наличии, затем выполняется install.sh. Подождите 310 мин и откройте адрес снова. Журнал: на 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) {