mirror of
https://github.com/andrey271192/amnezia_web.git
synced 2026-09-20 14:41:59 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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` в этом репозитории).
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "amnezia-admin",
|
||||
"version": "1.1.7",
|
||||
"version": "1.1.8",
|
||||
"private": true,
|
||||
"description": "amnezia_web — базовая панель AmneziaWG (только просмотр клиентов; полная версия — PRO).",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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
158
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 });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user