From 9d54c7c8b8a2e31479e22498f69a80905839738a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=91=D0=BE=D0=B1?= =?UTF-8?q?=D1=8B=D1=80=D0=B5=D0=B2?= Date: Fri, 15 May 2026 00:50:21 +0300 Subject: [PATCH] feat(community): optional PRO install via GitHub token in UI - POST /api/community/run-private-install fetches private install.sh and runs bash detached (202 Accepted) - Gated by ALLOW_COMMUNITY_GITHUB_ACTIVATION + AMNEZIA_EDITION=community + admin session - Log appended to /data/community-install-last.log; install bash bundled in Dockerfile Co-authored-by: Cursor --- Dockerfile | 2 +- README.md | 6 +- package.json | 2 +- public/app.js | 71 +++++++++++++++++++++ public/styles.css | 45 +++++++++++++ server.js | 158 ++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 281 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index bc3e484..75057fd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ FROM node:22-alpine -RUN apk add --no-cache docker-cli openssh-client sshpass +RUN apk add --no-cache docker-cli bash openssh-client sshpass RUN mkdir -p /data && chmod 700 /data diff --git a/README.md b/README.md index 5647391..1989208 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Редакция **`community`** задаётся автоматически файлом **`.amnezia-panel-edition`** в корне репозитория (`community`) или переменной окружения **`AMNEZIA_EDITION=community`** в контейнере. Кнопка и текст про подписку настраиваются **`COMMUNITY_UPGRADE_URL`** и **`COMMUNITY_UPGRADE_PITCH`**. -**Безопасность:** доступ к Docker-сокету в контейнере панели эквивалентен root на хосте — используйте сложный пароль и ограничьте доступ по IP / TLS. +**Безопасность:** доступ к Docker-сокету в контейнере панели эквивалентен root на хосте — используйте сложный пароль и ограничьте доступ по IP / TLS. Поле установки PRO по GitHub‑токену (`ALLOW_COMMUNITY_GITHUB_ACTIVATION=1`) отключено по умолчанию: при включении любой авторизованный администратор может запускать произвольный `install.sh` на хосте через Docker‑сокет. Справочник по типичным сбоям и API (в т.ч. для PRO): в полной документации репозитория PRO. @@ -44,6 +44,10 @@ cd /opt/amnezia-admin && chmod +x scripts/install.sh && sudo SKIP_DOWNLOAD=1 bas | `COMMUNITY_UPGRADE_URL` | Страница подписки PRO на Boosty (зашита в код по умолчанию) | Переопределите, если смените уровень подписки | | `COMMUNITY_UPGRADE_PITCH` | _(текст по умолчанию в коде)_ | Текст под заголовком базовой версии | | `SKIP_LANDING` | `0` | `1` — без лендинга на порту 80 | +| `ALLOW_COMMUNITY_GITHUB_ACTIVATION` | `0` | `1` — в редакции community показывать ввод GitHub-токена и запускать приватный `install.sh` (доступ к репо с Contents Read / repo; **высокая чувствительность**, см. раздел безопасности) | +| `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` | Максимальный размер скачанного скрипта (байты) | Остальные переменные совместимы с образом панели (см. Dockerfile / `server.js` в этом репозитории). diff --git a/package.json b/package.json index d7065c4..29c8037 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "amnezia-admin", - "version": "1.1.7", + "version": "1.1.8", "private": true, "description": "amnezia_web — базовая панель AmneziaWG (только просмотр клиентов; полная версия — PRO).", "license": "MIT", diff --git a/public/app.js b/public/app.js index 5872d46..55bb981 100644 --- a/public/app.js +++ b/public/app.js @@ -70,6 +70,7 @@ let editionState = { upgradeUrl: null, upgradePitch: null, showDebugWg: true, + githubActivationAllowed: false, }; function applyEditionPayload(data) { @@ -81,6 +82,7 @@ function applyEditionPayload(data) { upgradeUrl: typeof ed.upgradeUrl === "string" ? ed.upgradeUrl : null, upgradePitch: typeof ed.upgradePitch === "string" ? ed.upgradePitch : null, showDebugWg: ed.showDebugWg !== false, + githubActivationAllowed: Boolean(ed.githubActivationAllowed), }; const titleEl = document.querySelector(".top h1"); if (titleEl) { @@ -122,6 +124,75 @@ function applyEditionPayload(data) { cta.textContent = "Разблокировать PRO (Boosty)"; wrap.append(textCol, cta); editionBanner.appendChild(wrap); + + if (editionState.githubActivationAllowed) { + const act = document.createElement("div"); + act.className = "edition-banner-activation muted"; + const cap = document.createElement("div"); + cap.className = "edition-banner-act-title"; + cap.textContent = + "Подписка получена и есть GitHub‑токен к приватному репозиторию? Запуск установки PRO с сервера (порт обычно сохранится; процесс см. журнал):"; + + const row = document.createElement("div"); + row.className = "edition-banner-act-row"; + + const inp = document.createElement("input"); + inp.type = "password"; + inp.autocomplete = "new-password"; + inp.spellcheck = false; + inp.placeholder = "Токен (classic: repo или fine‑grained: Contents Read)"; + inp.className = "edition-banner-act-input monospace"; + + const go = document.createElement("button"); + go.type = "button"; + go.className = "btn small primary"; + go.textContent = "Установить PRO"; + + const msg = document.createElement("p"); + msg.className = "edition-banner-act-msg muted"; + msg.setAttribute("role", "status"); + msg.textContent = ""; + + go.addEventListener("click", async () => { + msg.textContent = ""; + msg.className = "edition-banner-act-msg muted"; + const tok = inp.value.trim(); + if (!tok) { + msg.className = "edition-banner-act-msg status err"; + msg.textContent = "Вставьте токен."; + return; + } + go.disabled = true; + try { + const r = await fetch("/api/community/run-private-install", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ githubToken: tok }), + }); + const j = await r.json().catch(() => ({})); + if (!r.ok) { + msg.className = "edition-banner-act-msg status err"; + msg.textContent = j.error || `Ошибка ${r.status}`; + go.disabled = false; + return; + } + msg.className = "edition-banner-act-msg edition-act-ok muted"; + msg.textContent = + j.message || + "Установка запущена. Через 2–5 минут откройте панель снова по тому же адресу (или несколько раз обновите страницу)."; + inp.value = ""; + } catch (e) { + msg.className = "edition-banner-act-msg status err"; + msg.textContent = String(e?.message || e); + go.disabled = false; + } + }); + + row.append(inp, go); + act.append(cap, row, msg); + editionBanner.appendChild(act); + } } else { editionBanner.classList.add("hidden"); editionBanner.innerHTML = ""; diff --git a/public/styles.css b/public/styles.css index 85c91f9..b4a2530 100644 --- a/public/styles.css +++ b/public/styles.css @@ -950,3 +950,48 @@ tr:last-child td { .edition-banner-cta { flex-shrink: 0; } + +.edition-banner-activation { + margin-top: 0.85rem; + padding-top: 0.85rem; + border-top: 1px dashed rgba(125, 211, 252, 0.35); + width: 100%; +} + +.edition-banner-act-title { + font-size: 0.86rem; + line-height: 1.45; + margin-bottom: 0.5rem; +} + +.edition-banner-act-row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} + +.edition-banner-act-input { + flex: 1 1 240px; + min-width: 0; + padding: 0.45rem 0.65rem; + border-radius: 10px; + border: 1px solid rgba(148, 163, 184, 0.4); + background: rgba(15, 23, 42, 0.7); + color: var(--text); + font-size: 0.82rem; +} + +.edition-banner-act-input.monospace { + font-family: "JetBrains Mono", ui-monospace, monospace; +} + +.edition-banner-act-msg { + margin: 0.55rem 0 0 !important; + font-size: 0.82rem; + line-height: 1.35; +} + +.edition-act-ok { + color: #a7f3d0 !important; +} diff --git a/server.js b/server.js index d18777e..01430ba 100644 --- a/server.js +++ b/server.js @@ -40,6 +40,13 @@ const UI_HIDDEN = resolveUiHidden(); const AMNEZIA_EDITION = (process.env.AMNEZIA_EDITION || "pro").trim().toLowerCase(); const IS_COMMUNITY = AMNEZIA_EDITION === "community"; +const ALLOW_COMMUNITY_GITHUB_ACTIVATION = envTruthy(process.env.ALLOW_COMMUNITY_GITHUB_ACTIVATION); +const COMMUNITY_PRIVATE_INSTALL_SCRIPT_URL = + process.env.COMMUNITY_PRIVATE_INSTALL_SCRIPT_URL?.trim() || + "https://raw.githubusercontent.com/andrey271192/amnezia_web-pro/main/scripts/install.sh"; +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_UPGRADE_URL = process.env.COMMUNITY_UPGRADE_URL?.trim() || "https://boosty.to/andrey27/purchase/3906453?ssource=DIRECT&share=subscription_link"; @@ -54,6 +61,7 @@ function editionPayload() { upgradeUrl: IS_COMMUNITY ? COMMUNITY_UPGRADE_URL : null, upgradePitch: IS_COMMUNITY ? COMMUNITY_UPGRADE_PITCH : null, showDebugWg: !IS_COMMUNITY, + githubActivationAllowed: ALLOW_COMMUNITY_GITHUB_ACTIVATION && IS_COMMUNITY, }; } @@ -349,6 +357,23 @@ function rejectCommunityProOnly(res) { }); } +/** Токен GitHub только из печатаемых ASCII без пробелов/newline (classic ghp_* / github_pat_*). */ +function validateGithubBearerToken(tok) { + if (typeof tok !== "string") return false; + const s = tok.trim(); + if (s.length < 20 || s.length > 4096) return false; + return /^[!-~]+$/.test(s); +} + +function privateInstallScriptUrlLogged() { + try { + const u = new URL(COMMUNITY_PRIVATE_INSTALL_SCRIPT_URL); + return `${u.hostname}${u.pathname}`; + } catch { + return "(invalid PRIVATE_INSTALL_SCRIPT_URL)"; + } +} + function requireProTier(_req, res, next) { if (!IS_COMMUNITY) { next(); @@ -1365,8 +1390,141 @@ if (UI_HIDDEN.users || UI_HIDDEN.warp || UI_HIDDEN.cascade) { if (IS_COMMUNITY) { console.warn(`Редакция community (только просмотр клиентов). PRO: ${COMMUNITY_UPGRADE_URL}`); } +if (ALLOW_COMMUNITY_GITHUB_ACTIVATION && IS_COMMUNITY) { + console.warn( + "Разрешена установка PRO из UI (ALLOW_COMMUNITY_GITHUB_ACTIVATION): GitHub-токен не сохранять в журналах; защитите доступ к паролю панели и Docker-сокету.", + ); +} app.use(express.json({ limit: "512kb" })); +app.post("/api/community/run-private-install", requireAuth, async (req, res) => { + if (!IS_COMMUNITY || !ALLOW_COMMUNITY_GITHUB_ACTIVATION) { + return res.status(403).json({ + error: "Запуск установки PRO из панели отключён.", + }); + } + if (communityPrivateInstallBusy) { + return res.status(429).json({ + error: "Установка PRO уже выполняется. Через 1–2 минуты обновите страницу или см. журнал ниже.", + }); + } + + const token = typeof req.body?.githubToken === "string" ? req.body.githubToken.trim() : ""; + if (!validateGithubBearerToken(token)) { + return res.status(400).json({ + error: + "Нужен GitHub-токен с доступом к приватному репозиторию (classic: repo; fine-grained: Contents Read для репозитория со scripts/install.sh).", + }); + } + + let tmpDir = ""; + communityPrivateInstallBusy = true; + + try { + const ac = new AbortController(); + const timeoutMs = Number(process.env.COMMUNITY_INSTALL_FETCH_MS || 60_000) || 60_000; + const tmo = setTimeout(() => ac.abort(), timeoutMs); + const ghRes = await fetch(COMMUNITY_PRIVATE_INSTALL_SCRIPT_URL, { + redirect: "follow", + headers: { + Authorization: `token ${token}`, + Accept: "*/*", + "User-Agent": "amnezia-web-community-private-install", + }, + signal: ac.signal, + }).finally(() => clearTimeout(tmo)); + + const buf = Buffer.from(await ghRes.arrayBuffer()); + if (!ghRes.ok || buf.byteLength === 0) { + communityPrivateInstallBusy = false; + return res.status(400).json({ + error: `Не удалось скачать install (${ghRes.status}). Проверьте токен и URL.`, + urlForDebug: privateInstallScriptUrlLogged(), + }); + } + if (buf.byteLength > PRIVATE_INSTALL_SCRIPT_MAX_BYTES) { + communityPrivateInstallBusy = false; + return res.status(400).json({ error: "Скачанный скрипт слишком большой — отказ." }); + } + + tmpDir = fs.mkdtempSync(path.join("/tmp", "amnezia-priv-inst-")); + const scriptPath = path.join(tmpDir, "install.sh"); + fs.writeFileSync(scriptPath, buf); + fs.chmodSync(scriptPath, 0o700); + + ensureDataDir(); + const logAbs = path.join(DATA_DIR, "community-install-last.log"); + 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 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 }); + + let finalized = false; + const finalize = (code = null, signal = null, errText = "") => { + if (finalized) return; + finalized = true; + communityPrivateInstallBusy = false; + const foot = `\n--- завершено ${new Date().toISOString()}, code=${code ?? "?"}${ + signal ? ` signal=${signal}` : "" + }${errText ? ` err=${errText}` : ""} ---\n`; + try { + fs.appendFileSync(logAbs, foot); + } catch { + // + } + logStream.end(() => { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // + } + }); + }; + + 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-панель (до возможной замены образа).", + logInsideContainer: "community-install-last.log", + }); + } catch (e) { + communityPrivateInstallBusy = false; + if (tmpDir) { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // + } + } + const msg = + e?.name === "AbortError" + ? "Таймаут загрузки install.sh с GitHub." + : e?.message || String(e); + return res.status(400).json({ error: msg }); + } +}); + app.get("/health", (_req, res) => { res.json({ ok: true }); });