From 74849a8e2c8e2ff154758796f11532ebf0ff6777 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=91=D0=BE=D0=B1=D1=8B=D1=80=D0=B5=D0=B2=20=D0=90=D0=BD?= =?UTF-8?q?=D0=B4=D1=80=D0=B5=D0=B9?= Date: Fri, 23 Oct 2026 01:08:09 +0300 Subject: [PATCH] v1.4.0: Xray VLESS-Reality, fully open panel (no community edition) Co-Authored-By: Claude Opus 5.5 --- README.md | 9 +- package.json | 2 +- public/app.js | 310 +++++++++++++++++++++-------------------- public/index.html | 47 ++++++- public/styles.css | 30 ---- scripts/install.sh | 14 -- server.js | 333 ++++++++++++++++++++++++++++++--------------- 7 files changed, 428 insertions(+), 317 deletions(-) diff --git a/README.md b/README.md index d39e9f3..e76945d 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ # amnezia_web-PRO -## Public PRO panel +Полностью открытая веб-панель для сервера Amnezia: все функции бесплатны, без платных или закрытых редакций. Один репозиторий, один установщик. -**amnezia_web-PRO** — public full Amnezia Web panel. One repository, one installer, no private download flow and no separate deploy repository. +## Что нового в 1.4.0 -Base project **[amnezia_web](https://github.com/andrey271192/amnezia_web)** remains a lighter open panel. This repository contains the full PRO feature set by default. +- **Xray VLESS‑Reality** — раздел «Xray VLESS‑Reality»: установка с выбором TCP‑порта и сайта‑маскировки, клиенты (создать, выключить/включить, удалить), ссылка `vless://` для Amnezia VPN, v2rayNG, Hiddify, Streisand, Happ. Файлы в `/opt/amnezia/xray` в том же формате, что у приложения Amnezia. +- **Всё открыто:** убрана редакция «community / только просмотр» и переменные `AMNEZIA_EDITION`, `COMMUNITY_UPGRADE_*` — все функции доступны всем. ## Что нового в 1.3.0 @@ -261,7 +262,7 @@ UI_HIDE_SECTIONS=warp,cascade curl -fsSL https://raw.githubusercontent.com/andre Что нужно человеку, который ставит связку: -1. На **этом** (основном) VPS — обычный AmneziaWG и панель PRO; здесь блок **«Новый клиент под каскад»** **создаёт peer и ключи** и выдаёт скачанный **`.conf`**, в котором **Endpoint** уже указывает на промежуточную машину. +1. На **этом** (основном) VPS — обычный AmneziaWG и эта панель; здесь блок **«Новый клиент под каскад»** **создаёт peer и ключи** и выдаёт скачанный **`.conf`**, в котором **Endpoint** уже указывает на промежуточную машину. 2. На **промежуточном** сервере нужен **проброс порта DNAT до основного VPS** на тот же **ListenPort**, что слушает туннель здесь (либо свой внешний порт → тогда его указывают в форме как «Порт Endpoint»). Готовая панель для правил проброса (iptables DNAT NAT «под ключ»): репозиторий **[andrey271192/kaskad_web_vpn](https://github.com/andrey271192/kaskad_web_vpn)** — установка `curl … install.sh | sudo bash`, затем вход в браузере на промежуточный VPS по порту из README (например `8088/setup`) и настройка правила «вход → IP основного VPS:порт WG». Подробные шаги и переменные среды см. там же в README. diff --git a/package.json b/package.json index f7e7bce..96d0849 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "amnezia-admin", - "version": "1.3.0", + "version": "1.4.0", "private": false, "description": "amnezia_web-PRO — веб-панель AmneziaWG: несколько инстансов, WARP, каскад/Endpoint, экспорт .conf (Docker)", "license": "MIT", diff --git a/public/app.js b/public/app.js index 14ddf2f..a4f89aa 100644 --- a/public/app.js +++ b/public/app.js @@ -74,77 +74,6 @@ let warpSshPendingCmd = null; /** Какие панели скрыты настройкой сервера (`UI_HIDE_SECTIONS`). */ let uiHidden = { users: false, warp: false, cascade: false, mtproto: false }; -const editionBanner = document.querySelector("#edition-banner"); -const DEFAULT_HEADER_SUB = document.querySelector(".top .sub")?.textContent?.trim() || ""; - -/** Состояние редакции панели (community = только просмотр клиентов). */ -let editionState = { - tier: "pro", - readOnlyClients: false, - upgradeUrl: null, - upgradePitch: null, - showDebugWg: true, -}; - -function applyEditionPayload(data) { - const ed = data?.edition; - if (!ed || typeof ed !== "object") return; - editionState = { - tier: ed.tier === "community" ? "community" : "pro", - readOnlyClients: Boolean(ed.readOnlyClients), - upgradeUrl: typeof ed.upgradeUrl === "string" ? ed.upgradeUrl : null, - upgradePitch: typeof ed.upgradePitch === "string" ? ed.upgradePitch : null, - showDebugWg: ed.showDebugWg !== false, - }; - const titleEl = document.querySelector(".top h1"); - if (titleEl) { - titleEl.textContent = - editionState.tier === "community" - ? "Пользователи AmneziaWG FREE" - : "Пользователи AmneziaWG PRO"; - } - const subEl = document.querySelector(".top .sub"); - if (subEl) { - if (editionState.tier === "community") { - subEl.textContent = - "Базовая панель amnezia_web: только просмотр клиентов AmneziaWG и статусов. Управление туннелем, экспорт .conf, каскад, Cloudflare WARP, MTProto‑прокси Telegram и синхронизация времени хоста — в версии PRO."; - } else { - subEl.textContent = DEFAULT_HEADER_SUB; - } - } - if (editionBanner) { - if (editionState.tier === "community") { - editionBanner.classList.remove("hidden"); - editionBanner.innerHTML = ""; - const wrap = document.createElement("div"); - wrap.className = "edition-banner-inner"; - const textCol = document.createElement("div"); - textCol.className = "edition-banner-text"; - const strong = document.createElement("strong"); - strong.textContent = "Базовая версия · только просмотр"; - const pitch = document.createElement("p"); - pitch.className = "edition-banner-pitch muted"; - pitch.textContent = editionState.upgradePitch || ""; - textCol.append(strong, pitch); - const cta = document.createElement("a"); - cta.className = "btn small primary edition-banner-cta"; - cta.rel = "noopener noreferrer"; - cta.target = "_blank"; - cta.href = - editionState.upgradeUrl || - "https://github.com/andrey271192/amnezia_web-PRO"; - cta.textContent = "Открыть PRO на GitHub"; - wrap.append(textCol, cta); - editionBanner.appendChild(wrap); - } else { - editionBanner.classList.add("hidden"); - editionBanner.innerHTML = ""; - } - } - document.querySelector(".clock-host-sync")?.classList.toggle("hidden", editionState.readOnlyClients); - if (wgRawDetails) wgRawDetails.hidden = uiHidden.users || !editionState.showDebugWg; -} - /** @param {string} [tgLink] */ function setMtprotoLinkCard(tgLink) { const t = typeof tgLink === "string" ? tgLink.trim() : ""; @@ -350,7 +279,7 @@ function applyUiHiddenFromPayload(data) { }; } if (usersPanel) usersPanel.hidden = uiHidden.users; - if (wgRawDetails) wgRawDetails.hidden = uiHidden.users || !editionState.showDebugWg; + if (wgRawDetails) wgRawDetails.hidden = uiHidden.users; if (cascadePanel) cascadePanel.hidden = uiHidden.cascade; if (warpPanel) warpPanel.hidden = uiHidden.warp; if (mtprotoPanel) mtprotoPanel.hidden = uiHidden.mtproto; @@ -586,7 +515,6 @@ async function checkSession() { async function loadProtocols() { try { const data = await api("/api/protocols"); - applyEditionPayload(data); protoLabel.textContent = `Протокол: ${data.currentLabel || "AmneziaWG"}`; if (profileHintEl) { if (data.singleProfile && typeof data.profilesPersistHint === "string" && data.profilesPersistHint) { @@ -630,6 +558,7 @@ loginForm.addEventListener("submit", async (ev) => { await loadClients(); await loadInstances(); await loadSettings(); + await loadXray(); } catch (e) { loginError.textContent = String(e.message || e); } @@ -649,6 +578,7 @@ refreshBtn.addEventListener("click", () => { loadClients(); loadInstances(); loadSettings(); + loadXray(); }); const cascadeForm = document.querySelector("#cascade-form"); @@ -838,14 +768,6 @@ async function loadTimeSyncCaps() { const btn = document.querySelector("#sync-host-time"); try { const c = await api("/api/time-sync-capabilities"); - if (editionState.readOnlyClients || c.communityBlocked) { - if (hint) { - hint.textContent = - "В базовой версии синхронизация времени хоста по SSH недоступна — это функция PRO."; - } - if (btn) btn.disabled = true; - return; - } if (hint) { hint.textContent = c.hostTimeSync ? `Записывается UTC-момент с этого устройства на хост по SSH (root@${c.sshHost}). Пояс строки «Сервер»: ${c.serverClockTimeZone}. Пароль не сохраняется.` @@ -938,38 +860,27 @@ function renderRows(clients) { nameWrap.className = "name-cell"; const strong = document.createElement("strong"); strong.textContent = c.name; - if (!editionState.readOnlyClients) { - const renameWrap = document.createElement("div"); - renameWrap.className = "rename-inline"; - renameWrap.appendChild( - btn("Переименовать", "btn small ghost", () => void renameClient(c)) - ); - nameWrap.append(strong, renameWrap); - if (!c.exportAvailable) { - const hintFold = document.createElement("details"); - hintFold.className = "hint-mini"; - const sum = document.createElement("summary"); - sum.textContent = c.missingClientTable - ? "Peer найден в конфиге, но нет записи clientsTable" - : "Нет готового .conf на сервере (last_config)"; - hintFold.appendChild(sum); - const exHint = document.createElement("p"); - exHint.className = "muted export-missing-hint"; - exHint.textContent = c.missingClientTable - ? "Клиент активен в awg0.conf, но веб не знает имя и last_config. Вставьте его .conf в блок «Импорт клиента», чтобы восстановить запись и экспорт." - : "Конфиг с сервера недоступен (нет last_config). Создайте клиента с нужным Endpoint в блоке «Новый клиент под каскад» ниже или возьмите ключ из приложения Amnezia."; - hintFold.appendChild(exHint); - nameWrap.appendChild(hintFold); - } - } else { - nameWrap.appendChild(strong); - const roHint = document.createElement("p"); - roHint.className = "muted hint-mini"; - roHint.style.margin = "0.35rem 0 0"; - roHint.textContent = c.exportAvailable - ? "На сервере есть данные для .conf — скачивание доступно в PRO." - : "Нет last_config на сервере — полный конфиг в приложении Amnezia."; - nameWrap.appendChild(roHint); + const renameWrap = document.createElement("div"); + renameWrap.className = "rename-inline"; + renameWrap.appendChild( + btn("Переименовать", "btn small ghost", () => void renameClient(c)) + ); + nameWrap.append(strong, renameWrap); + if (!c.exportAvailable) { + const hintFold = document.createElement("details"); + hintFold.className = "hint-mini"; + const sum = document.createElement("summary"); + sum.textContent = c.missingClientTable + ? "Peer найден в конфиге, но нет записи clientsTable" + : "Нет готового .conf на сервере (last_config)"; + hintFold.appendChild(sum); + const exHint = document.createElement("p"); + exHint.className = "muted export-missing-hint"; + exHint.textContent = c.missingClientTable + ? "Клиент активен в awg0.conf, но веб не знает имя и last_config. Вставьте его .conf в блок «Импорт клиента», чтобы восстановить запись и экспорт." + : "Конфиг с сервера недоступен (нет last_config). Создайте клиента с нужным Endpoint в блоке «Новый клиент под каскад» ниже или возьмите ключ из приложения Amnezia."; + hintFold.appendChild(exHint); + nameWrap.appendChild(hintFold); } nameTd.appendChild(nameWrap); @@ -991,51 +902,42 @@ function renderRows(clients) { const dateLine = document.createElement("div"); dateLine.textContent = formatLastDisconnect(c); offTd.appendChild(dateLine); - if (!editionState.readOnlyClients) { - const dtWrap = document.createElement("div"); - dtWrap.className = "rename-inline"; - dtWrap.appendChild( - btn("Задать дату", "btn small ghost", () => openEditDisconnectDialog(c)) - ); - offTd.appendChild(dtWrap); - } + const dtWrap = document.createElement("div"); + dtWrap.className = "rename-inline"; + dtWrap.appendChild( + btn("Задать дату", "btn small ghost", () => openEditDisconnectDialog(c)) + ); + offTd.appendChild(dtWrap); const actTd = document.createElement("td"); actTd.className = "actions"; - if (editionState.readOnlyClients) { - const lock = document.createElement("span"); - lock.className = "muted"; - lock.textContent = "Только PRO"; - actTd.appendChild(lock); + if (c.activeInConf) { + actTd.appendChild(iconBtn("⏻", "Выключить", "btn small ghost icon", () => openDisableDialog(c))); } else { - if (c.activeInConf) { - actTd.appendChild(iconBtn("⏻", "Выключить", "btn small ghost icon", () => openDisableDialog(c))); - } else { - actTd.appendChild( - iconBtn("▶", "Включить", "btn small primary icon", () => mutate("/api/clients/enable", c.clientId)) - ); - } - if (c.exportAvailable) { - const direct = document.createElement("a"); - direct.className = "btn small ghost"; - direct.href = clientExportGetUrl(c.clientId); - direct.textContent = "🔗"; direct.classList.add("icon"); - direct.rel = "noopener"; - direct.title = - "Открыть в новой вкладке — скачается .conf, если вы авторизованы в этой панели (cookies)."; - - actTd.appendChild(iconBtn("⬇", "Скачать .conf", "btn small ghost icon", () => void downloadClientConfig(c))); - actTd.appendChild(direct); - actTd.appendChild( - iconBtn("⧉", "Копировать URL", "btn small ghost icon", async () => { - const ok = await copyTextToClipboard(clientExportGetUrl(c.clientId)); - setStatus(ok ? "Ссылка скопирована (вставьте в браузер, будучи залогиненным)." : "Не удалось скопировать.", !ok); - }), - ); - } - actTd.appendChild(iconBtn("🗑", "Удалить", "btn small warn icon", () => confirmDelete(c.name, c.clientId))); + actTd.appendChild( + iconBtn("▶", "Включить", "btn small primary icon", () => mutate("/api/clients/enable", c.clientId)) + ); } + if (c.exportAvailable) { + const direct = document.createElement("a"); + direct.className = "btn small ghost"; + direct.href = clientExportGetUrl(c.clientId); + direct.textContent = "🔗"; direct.classList.add("icon"); + direct.rel = "noopener"; + direct.title = + "Открыть в новой вкладке — скачается .conf, если вы авторизованы в этой панели (cookies)."; + + actTd.appendChild(iconBtn("⬇", "Скачать .conf", "btn small ghost icon", () => void downloadClientConfig(c))); + actTd.appendChild(direct); + actTd.appendChild( + iconBtn("⧉", "Копировать URL", "btn small ghost icon", async () => { + const ok = await copyTextToClipboard(clientExportGetUrl(c.clientId)); + setStatus(ok ? "Ссылка скопирована (вставьте в браузер, будучи залогиненным)." : "Не удалось скопировать.", !ok); + }), + ); + } + actTd.appendChild(iconBtn("🗑", "Удалить", "btn small warn icon", () => confirmDelete(c.name, c.clientId))); tr.append(nameTd, ipTd, stTd, offTd, actTd); rowsEl.appendChild(tr); @@ -1651,7 +1553,6 @@ async function loadClients() { try { setStatus("Загрузка…", false); const data = await api(`/api/clients${currentProfileQueryString()}`); - applyEditionPayload(data); applyUiHiddenFromPayload(data); const pref = data.profileLabel ? `${data.profileLabel} · ` : ""; if (uiHidden.users) { @@ -1661,10 +1562,7 @@ async function loadClients() { } else { if (syncPeersBtn) { syncPeersBtn.hidden = false; - syncPeersBtn.disabled = editionState.readOnlyClients; - syncPeersBtn.title = editionState.readOnlyClients - ? "Доступно в PRO" - : "Создать недостающие строки clientsTable из активных [Peer] текущего инстанса"; + syncPeersBtn.title = "Создать недостающие строки clientsTable из активных [Peer] текущего инстанса"; } peerCountEl.textContent = `${pref}${data.clients.length} в таблице · ${data.peerCount} peer`; wgShowEl.textContent = data.wgShow || ""; @@ -1733,6 +1631,7 @@ async function boot() { await loadClients(); await loadInstances(); await loadSettings(); + await loadXray(); } else { showLogin(); loginPassword.focus(); @@ -1788,3 +1687,100 @@ document.querySelector("#upstream-form")?.addEventListener("submit", async (ev) if (b) b.disabled = false; } }); + +async function loadXray() { + const line = document.querySelector("#xray-status-line"); + try { + const x = await api("/api/xray/status"); + document.querySelector("#xray-install-form").style.display = x.installed ? "none" : ""; + document.querySelector("#xray-manage").style.display = x.installed ? "" : "none"; + if (line) { + line.textContent = !x.installed + ? "не установлен" + : x.running + ? `порт ${x.port} · ${x.sni} · клиентов: ${x.clients.length}` + : "контейнер остановлен"; + } + if (x.error) setStatus(x.error, true); + const rows = document.querySelector("#xray-rows"); + rows.innerHTML = ""; + for (const c of x.clients) { + const tr = document.createElement("tr"); + const nameTd = document.createElement("td"); + nameTd.innerHTML = `${escapeHtml(c.name)}`; + const stTd = document.createElement("td"); + stTd.innerHTML = `${c.enabled ? "Включён" : "Выключен"}`; + const actTd = document.createElement("td"); + actTd.className = "actions"; + const act = (action) => async () => { + if (action === "delete" && !confirm(`Удалить клиента ${c.name}?`)) return; + try { + await api("/api/xray/clients/update", { method: "POST", body: JSON.stringify({ id: c.id, action }) }); + await loadXray(); + } catch (e) { + setStatus(String(e.message || e), true); + } + }; + actTd.append( + iconBtn("⧉", "Копировать ссылку vless://", "btn small ghost icon", async () => { + const ok = await copyTextToClipboard(c.link); + setStatus(ok ? `Ссылка ${c.name} скопирована.` : "Не удалось скопировать.", !ok); + }), + c.enabled + ? iconBtn("⏻", "Выключить", "btn small ghost icon", act("disable")) + : iconBtn("▶", "Включить", "btn small primary icon", act("enable")), + iconBtn("🗑", "Удалить", "btn small warn icon", act("delete")), + ); + tr.append(nameTd, stTd, actTd); + rows.appendChild(tr); + } + } catch (e) { + if (line) line.textContent = String(e.message || e); + } +} + +document.querySelector("#xray-install-form")?.addEventListener("submit", async (ev) => { + ev.preventDefault(); + const b = ev.target.querySelector("button[type=submit]"); + if (b) b.disabled = true; + try { + setStatus("Ставлю Xray (сборка образа ~1 мин)…", false); + await api("/api/xray/install", { + method: "POST", + body: JSON.stringify({ + port: document.querySelector("#xray-port").value, + sni: document.querySelector("#xray-sni").value, + }), + }); + setStatus("Xray установлен. Создайте клиента.", false); + await loadXray(); + } catch (e) { + setStatus(String(e.message || e), true); + } finally { + if (b) b.disabled = false; + } +}); + +document.querySelector("#xray-client-form")?.addEventListener("submit", async (ev) => { + ev.preventDefault(); + const input = document.querySelector("#xray-client-name"); + try { + const r = await api("/api/xray/clients/create", { method: "POST", body: JSON.stringify({ name: input.value }) }); + input.value = ""; + const ok = await copyTextToClipboard(r.link); + setStatus(ok ? "Клиент создан, ссылка скопирована." : "Клиент создан.", false); + await loadXray(); + } catch (e) { + setStatus(String(e.message || e), true); + } +}); + +document.querySelector("#xray-remove")?.addEventListener("click", async () => { + if (!confirm("Удалить контейнер Xray? Клиенты Xray перестанут работать.")) return; + try { + await api("/api/xray/remove", { method: "POST", body: "{}" }); + await loadXray(); + } catch (e) { + setStatus(String(e.message || e), true); + } +}); diff --git a/public/index.html b/public/index.html index 6f4aa3d..19ed9bd 100644 --- a/public/index.html +++ b/public/index.html @@ -8,14 +8,14 @@ - +