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 <cursoragent@cursor.com>
This commit is contained in:
Андрей Бобырев
2026-05-15 00:50:21 +03:00
parent 9c28c022ac
commit 9d54c7c8b8
6 changed files with 281 additions and 3 deletions

View File

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

View File

@@ -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` в этом репозитории).

View File

@@ -1,6 +1,6 @@
{
"name": "amnezia-admin",
"version": "1.1.7",
"version": "1.1.8",
"private": true,
"description": "amnezia_web — базовая панель AmneziaWG (только просмотр клиентов; полная версия — PRO).",
"license": "MIT",

View File

@@ -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 или finegrained: 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 ||
"Установка запущена. Через 25 минут откройте панель снова по тому же адресу (или несколько раз обновите страницу).";
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 = "";

View File

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

158
server.js
View File

@@ -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 уже выполняется. Через 12 минуты обновите страницу или см. журнал ниже.",
});
}
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 запущена. Панель может прерваться — через 25 минут откройте её снова по тому же адресу. Журнал пишется в /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 });
});