mirror of
https://github.com/andrey271192/amnezia_web-PRO.git
synced 2026-11-04 00:43:15 +00:00
v1.4.0: Xray VLESS-Reality, fully open panel (no community edition)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,11 @@
|
|||||||
# amnezia_web-PRO
|
# 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
|
## Что нового в 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»).
|
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.
|
Готовая панель для правил проброса (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.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "amnezia-admin",
|
"name": "amnezia-admin",
|
||||||
"version": "1.3.0",
|
"version": "1.4.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "amnezia_web-PRO — веб-панель AmneziaWG: несколько инстансов, WARP, каскад/Endpoint, экспорт .conf (Docker)",
|
"description": "amnezia_web-PRO — веб-панель AmneziaWG: несколько инстансов, WARP, каскад/Endpoint, экспорт .conf (Docker)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
208
public/app.js
208
public/app.js
@@ -74,77 +74,6 @@ let warpSshPendingCmd = null;
|
|||||||
/** Какие панели скрыты настройкой сервера (`UI_HIDE_SECTIONS`). */
|
/** Какие панели скрыты настройкой сервера (`UI_HIDE_SECTIONS`). */
|
||||||
let uiHidden = { users: false, warp: false, cascade: false, mtproto: false };
|
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] */
|
/** @param {string} [tgLink] */
|
||||||
function setMtprotoLinkCard(tgLink) {
|
function setMtprotoLinkCard(tgLink) {
|
||||||
const t = typeof tgLink === "string" ? tgLink.trim() : "";
|
const t = typeof tgLink === "string" ? tgLink.trim() : "";
|
||||||
@@ -350,7 +279,7 @@ function applyUiHiddenFromPayload(data) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (usersPanel) usersPanel.hidden = uiHidden.users;
|
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 (cascadePanel) cascadePanel.hidden = uiHidden.cascade;
|
||||||
if (warpPanel) warpPanel.hidden = uiHidden.warp;
|
if (warpPanel) warpPanel.hidden = uiHidden.warp;
|
||||||
if (mtprotoPanel) mtprotoPanel.hidden = uiHidden.mtproto;
|
if (mtprotoPanel) mtprotoPanel.hidden = uiHidden.mtproto;
|
||||||
@@ -586,7 +515,6 @@ async function checkSession() {
|
|||||||
async function loadProtocols() {
|
async function loadProtocols() {
|
||||||
try {
|
try {
|
||||||
const data = await api("/api/protocols");
|
const data = await api("/api/protocols");
|
||||||
applyEditionPayload(data);
|
|
||||||
protoLabel.textContent = `Протокол: ${data.currentLabel || "AmneziaWG"}`;
|
protoLabel.textContent = `Протокол: ${data.currentLabel || "AmneziaWG"}`;
|
||||||
if (profileHintEl) {
|
if (profileHintEl) {
|
||||||
if (data.singleProfile && typeof data.profilesPersistHint === "string" && data.profilesPersistHint) {
|
if (data.singleProfile && typeof data.profilesPersistHint === "string" && data.profilesPersistHint) {
|
||||||
@@ -630,6 +558,7 @@ loginForm.addEventListener("submit", async (ev) => {
|
|||||||
await loadClients();
|
await loadClients();
|
||||||
await loadInstances();
|
await loadInstances();
|
||||||
await loadSettings();
|
await loadSettings();
|
||||||
|
await loadXray();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
loginError.textContent = String(e.message || e);
|
loginError.textContent = String(e.message || e);
|
||||||
}
|
}
|
||||||
@@ -649,6 +578,7 @@ refreshBtn.addEventListener("click", () => {
|
|||||||
loadClients();
|
loadClients();
|
||||||
loadInstances();
|
loadInstances();
|
||||||
loadSettings();
|
loadSettings();
|
||||||
|
loadXray();
|
||||||
});
|
});
|
||||||
|
|
||||||
const cascadeForm = document.querySelector("#cascade-form");
|
const cascadeForm = document.querySelector("#cascade-form");
|
||||||
@@ -838,14 +768,6 @@ async function loadTimeSyncCaps() {
|
|||||||
const btn = document.querySelector("#sync-host-time");
|
const btn = document.querySelector("#sync-host-time");
|
||||||
try {
|
try {
|
||||||
const c = await api("/api/time-sync-capabilities");
|
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) {
|
if (hint) {
|
||||||
hint.textContent = c.hostTimeSync
|
hint.textContent = c.hostTimeSync
|
||||||
? `Записывается UTC-момент с этого устройства на хост по SSH (root@${c.sshHost}). Пояс строки «Сервер»: ${c.serverClockTimeZone}. Пароль не сохраняется.`
|
? `Записывается UTC-момент с этого устройства на хост по SSH (root@${c.sshHost}). Пояс строки «Сервер»: ${c.serverClockTimeZone}. Пароль не сохраняется.`
|
||||||
@@ -938,7 +860,6 @@ function renderRows(clients) {
|
|||||||
nameWrap.className = "name-cell";
|
nameWrap.className = "name-cell";
|
||||||
const strong = document.createElement("strong");
|
const strong = document.createElement("strong");
|
||||||
strong.textContent = c.name;
|
strong.textContent = c.name;
|
||||||
if (!editionState.readOnlyClients) {
|
|
||||||
const renameWrap = document.createElement("div");
|
const renameWrap = document.createElement("div");
|
||||||
renameWrap.className = "rename-inline";
|
renameWrap.className = "rename-inline";
|
||||||
renameWrap.appendChild(
|
renameWrap.appendChild(
|
||||||
@@ -961,16 +882,6 @@ function renderRows(clients) {
|
|||||||
hintFold.appendChild(exHint);
|
hintFold.appendChild(exHint);
|
||||||
nameWrap.appendChild(hintFold);
|
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);
|
|
||||||
}
|
|
||||||
nameTd.appendChild(nameWrap);
|
nameTd.appendChild(nameWrap);
|
||||||
|
|
||||||
const ipTd = document.createElement("td");
|
const ipTd = document.createElement("td");
|
||||||
@@ -991,24 +902,16 @@ function renderRows(clients) {
|
|||||||
const dateLine = document.createElement("div");
|
const dateLine = document.createElement("div");
|
||||||
dateLine.textContent = formatLastDisconnect(c);
|
dateLine.textContent = formatLastDisconnect(c);
|
||||||
offTd.appendChild(dateLine);
|
offTd.appendChild(dateLine);
|
||||||
if (!editionState.readOnlyClients) {
|
|
||||||
const dtWrap = document.createElement("div");
|
const dtWrap = document.createElement("div");
|
||||||
dtWrap.className = "rename-inline";
|
dtWrap.className = "rename-inline";
|
||||||
dtWrap.appendChild(
|
dtWrap.appendChild(
|
||||||
btn("Задать дату", "btn small ghost", () => openEditDisconnectDialog(c))
|
btn("Задать дату", "btn small ghost", () => openEditDisconnectDialog(c))
|
||||||
);
|
);
|
||||||
offTd.appendChild(dtWrap);
|
offTd.appendChild(dtWrap);
|
||||||
}
|
|
||||||
|
|
||||||
const actTd = document.createElement("td");
|
const actTd = document.createElement("td");
|
||||||
actTd.className = "actions";
|
actTd.className = "actions";
|
||||||
|
|
||||||
if (editionState.readOnlyClients) {
|
|
||||||
const lock = document.createElement("span");
|
|
||||||
lock.className = "muted";
|
|
||||||
lock.textContent = "Только PRO";
|
|
||||||
actTd.appendChild(lock);
|
|
||||||
} else {
|
|
||||||
if (c.activeInConf) {
|
if (c.activeInConf) {
|
||||||
actTd.appendChild(iconBtn("⏻", "Выключить", "btn small ghost icon", () => openDisableDialog(c)));
|
actTd.appendChild(iconBtn("⏻", "Выключить", "btn small ghost icon", () => openDisableDialog(c)));
|
||||||
} else {
|
} else {
|
||||||
@@ -1035,7 +938,6 @@ function renderRows(clients) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
actTd.appendChild(iconBtn("🗑", "Удалить", "btn small warn icon", () => confirmDelete(c.name, c.clientId)));
|
actTd.appendChild(iconBtn("🗑", "Удалить", "btn small warn icon", () => confirmDelete(c.name, c.clientId)));
|
||||||
}
|
|
||||||
|
|
||||||
tr.append(nameTd, ipTd, stTd, offTd, actTd);
|
tr.append(nameTd, ipTd, stTd, offTd, actTd);
|
||||||
rowsEl.appendChild(tr);
|
rowsEl.appendChild(tr);
|
||||||
@@ -1651,7 +1553,6 @@ async function loadClients() {
|
|||||||
try {
|
try {
|
||||||
setStatus("Загрузка…", false);
|
setStatus("Загрузка…", false);
|
||||||
const data = await api(`/api/clients${currentProfileQueryString()}`);
|
const data = await api(`/api/clients${currentProfileQueryString()}`);
|
||||||
applyEditionPayload(data);
|
|
||||||
applyUiHiddenFromPayload(data);
|
applyUiHiddenFromPayload(data);
|
||||||
const pref = data.profileLabel ? `${data.profileLabel} · ` : "";
|
const pref = data.profileLabel ? `${data.profileLabel} · ` : "";
|
||||||
if (uiHidden.users) {
|
if (uiHidden.users) {
|
||||||
@@ -1661,10 +1562,7 @@ async function loadClients() {
|
|||||||
} else {
|
} else {
|
||||||
if (syncPeersBtn) {
|
if (syncPeersBtn) {
|
||||||
syncPeersBtn.hidden = false;
|
syncPeersBtn.hidden = false;
|
||||||
syncPeersBtn.disabled = editionState.readOnlyClients;
|
syncPeersBtn.title = "Создать недостающие строки clientsTable из активных [Peer] текущего инстанса";
|
||||||
syncPeersBtn.title = editionState.readOnlyClients
|
|
||||||
? "Доступно в PRO"
|
|
||||||
: "Создать недостающие строки clientsTable из активных [Peer] текущего инстанса";
|
|
||||||
}
|
}
|
||||||
peerCountEl.textContent = `${pref}${data.clients.length} в таблице · ${data.peerCount} peer`;
|
peerCountEl.textContent = `${pref}${data.clients.length} в таблице · ${data.peerCount} peer`;
|
||||||
wgShowEl.textContent = data.wgShow || "";
|
wgShowEl.textContent = data.wgShow || "";
|
||||||
@@ -1733,6 +1631,7 @@ async function boot() {
|
|||||||
await loadClients();
|
await loadClients();
|
||||||
await loadInstances();
|
await loadInstances();
|
||||||
await loadSettings();
|
await loadSettings();
|
||||||
|
await loadXray();
|
||||||
} else {
|
} else {
|
||||||
showLogin();
|
showLogin();
|
||||||
loginPassword.focus();
|
loginPassword.focus();
|
||||||
@@ -1788,3 +1687,100 @@ document.querySelector("#upstream-form")?.addEventListener("submit", async (ev)
|
|||||||
if (b) b.disabled = false;
|
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 = `<strong>${escapeHtml(c.name)}</strong>`;
|
||||||
|
const stTd = document.createElement("td");
|
||||||
|
stTd.innerHTML = `<span class="badge ${c.enabled ? "on" : "off"}">${c.enabled ? "Включён" : "Выключен"}</span>`;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,14 +8,14 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;600;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;600;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
|
||||||
<script>try{var t=localStorage.getItem("theme")||(matchMedia("(prefers-color-scheme: light)").matches?"light":"dark");document.documentElement.dataset.theme=t}catch(e){}</script>
|
<script>try{var t=localStorage.getItem("theme")||(matchMedia("(prefers-color-scheme: light)").matches?"light":"dark");document.documentElement.dataset.theme=t}catch(e){}</script>
|
||||||
<link rel="stylesheet" href="/styles.css?v=1.3.0">
|
<link rel="stylesheet" href="/styles.css?v=1.4.0">
|
||||||
</head>
|
</head>
|
||||||
<body class="page">
|
<body class="page">
|
||||||
<aside class="panel-promo-strip" role="complementary" aria-label="Поддержка и донат">
|
<aside class="panel-promo-strip" role="complementary" aria-label="Поддержка и донат">
|
||||||
<div class="panel-promo-strip-inner">
|
<div class="panel-promo-strip-inner">
|
||||||
<p class="panel-promo-subtitle muted">Поддержите проект через Boosty, Ozon СБП и Telegram — ниже те же ссылки в подвале.</p>
|
<p class="panel-promo-subtitle muted">Поддержите проект через Boosty, Ozon СБП и Telegram — ниже те же ссылки в подвале.</p>
|
||||||
<div class="panel-promo-links">
|
<div class="panel-promo-links">
|
||||||
<a class="panel-promo-a" href="https://github.com/andrey271192/amnezia_web-PRO" target="_blank" rel="noopener noreferrer">⭐ GitHub · PRO</a>
|
<a class="panel-promo-a" href="https://github.com/andrey271192/amnezia_web-PRO" target="_blank" rel="noopener noreferrer">⭐ GitHub</a>
|
||||||
<span class="panel-promo-sep" aria-hidden="true">·</span>
|
<span class="panel-promo-sep" aria-hidden="true">·</span>
|
||||||
<a class="panel-promo-a" href="https://boosty.to/andrey27/donate" target="_blank" rel="noopener noreferrer">💖 Boosty</a>
|
<a class="panel-promo-a" href="https://boosty.to/andrey27/donate" target="_blank" rel="noopener noreferrer">💖 Boosty</a>
|
||||||
<span class="panel-promo-sep" aria-hidden="true">·</span>
|
<span class="panel-promo-sep" aria-hidden="true">·</span>
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
</form>
|
</form>
|
||||||
<nav class="gate-donate muted" aria-label="Поддержка и донат">
|
<nav class="gate-donate muted" aria-label="Поддержка и донат">
|
||||||
Поддержка веб‑панелей (вне этого сервера):<br/>
|
Поддержка веб‑панелей (вне этого сервера):<br/>
|
||||||
<a href="https://github.com/andrey271192/amnezia_web-PRO" target="_blank" rel="noopener noreferrer">GitHub · PRO</a>
|
<a href="https://github.com/andrey271192/amnezia_web-PRO" target="_blank" rel="noopener noreferrer">GitHub</a>
|
||||||
<span class="gate-donate-dot" aria-hidden="true">·</span>
|
<span class="gate-donate-dot" aria-hidden="true">·</span>
|
||||||
<a href="https://boosty.to/andrey27/donate" target="_blank" rel="noopener noreferrer">Boosty</a>
|
<a href="https://boosty.to/andrey27/donate" target="_blank" rel="noopener noreferrer">Boosty</a>
|
||||||
<span class="gate-donate-dot" aria-hidden="true">·</span>
|
<span class="gate-donate-dot" aria-hidden="true">·</span>
|
||||||
@@ -78,7 +78,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div id="edition-banner" class="edition-banner hidden" role="region" aria-label="Редакция панели"></div>
|
|
||||||
|
|
||||||
<section class="toolbar">
|
<section class="toolbar">
|
||||||
<div class="pill proto-pill"><span class="dot ok"></span><span id="proto-label">Протокол: AmneziaWG</span></div>
|
<div class="pill proto-pill"><span class="dot ok"></span><span id="proto-label">Протокол: AmneziaWG</span></div>
|
||||||
@@ -190,6 +189,44 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details class="panel-fold" id="xray-panel">
|
||||||
|
<summary class="fold-summary">
|
||||||
|
<span class="fold-arrow" aria-hidden="true"></span>
|
||||||
|
<span class="fold-titles">
|
||||||
|
<span class="fold-h">Xray VLESS‑Reality</span>
|
||||||
|
<span class="muted fold-meta" id="xray-status-line"></span>
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<div class="panel-fold-body">
|
||||||
|
<p class="muted warp-intro">
|
||||||
|
Протокол для мест, где блокируют AmneziaWG: трафик выглядит как обычный HTTPS к сайту‑маскировке.
|
||||||
|
Ссылку <code class="inline">vless://</code> вставьте в Amnezia VPN, v2rayNG, Hiddify, Streisand или Happ. Нужен открытый TCP‑порт.
|
||||||
|
</p>
|
||||||
|
<form id="xray-install-form" class="instance-form">
|
||||||
|
<div class="instance-form-row">
|
||||||
|
<label for="xray-port">Порт (TCP)</label>
|
||||||
|
<input id="xray-port" type="number" min="1" max="65535" value="443">
|
||||||
|
</div>
|
||||||
|
<div class="instance-form-row">
|
||||||
|
<label for="xray-sni">Сайт‑маскировка</label>
|
||||||
|
<input id="xray-sni" type="text" value="www.google.com" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn primary">+ Установить Xray</button>
|
||||||
|
</form>
|
||||||
|
<div id="xray-manage" style="display:none">
|
||||||
|
<form id="xray-client-form" class="instance-form">
|
||||||
|
<div class="instance-form-row">
|
||||||
|
<label for="xray-client-name">Новый клиент</label>
|
||||||
|
<input id="xray-client-name" type="text" maxlength="64" placeholder="Имя" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn primary">+ Создать</button>
|
||||||
|
<button type="button" id="xray-remove" class="btn small warn">Удалить Xray</button>
|
||||||
|
</form>
|
||||||
|
<table class="clients-table"><tbody id="xray-rows"></tbody></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
<details class="panel-fold" id="instances-panel" open>
|
<details class="panel-fold" id="instances-panel" open>
|
||||||
<summary class="fold-summary">
|
<summary class="fold-summary">
|
||||||
<span class="fold-arrow" aria-hidden="true"></span>
|
<span class="fold-arrow" aria-hidden="true"></span>
|
||||||
@@ -423,6 +460,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<script src="/app.js?v=1.3.0" type="module"></script>
|
<script src="/app.js?v=1.4.0" type="module"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1097,36 +1097,6 @@ tr:last-child td {
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edition-banner {
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
padding: 0.85rem 1rem;
|
|
||||||
border-radius: 14px;
|
|
||||||
border: 1px solid rgba(125, 211, 252, 0.35);
|
|
||||||
background: linear-gradient(135deg, rgba(125, 211, 252, 0.12), rgba(94, 234, 212, 0.06));
|
|
||||||
}
|
|
||||||
|
|
||||||
.edition-banner.hidden {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edition-banner-inner {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.75rem 1rem;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edition-banner-text {
|
|
||||||
flex: 1 1 280px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edition-banner-pitch {
|
|
||||||
margin: 0.35rem 0 0;
|
|
||||||
font-size: 0.88rem;
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Telegram MTProto --- */
|
/* --- Telegram MTProto --- */
|
||||||
.mtproto-link-card {
|
.mtproto-link-card {
|
||||||
margin: 0.65rem 0 0.85rem;
|
margin: 0.65rem 0 0.85rem;
|
||||||
|
|||||||
@@ -283,20 +283,6 @@ if [[ -n "${BOOT_PW}" ]]; then
|
|||||||
RUN_ENV+=( -e "ADMIN_PASSWORD=${BOOT_PW}" )
|
RUN_ENV+=( -e "ADMIN_PASSWORD=${BOOT_PW}" )
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -n "${AMNEZIA_EDITION:-}" ]]; then
|
|
||||||
RUN_ENV+=( -e "AMNEZIA_EDITION=${AMNEZIA_EDITION}" )
|
|
||||||
elif [[ -f "${INSTALL_DIR}/.amnezia-panel-edition" ]]; then
|
|
||||||
__PE="$(tr -d '\r\n' <"${INSTALL_DIR}/.amnezia-panel-edition" | head -c 48)"
|
|
||||||
if [[ -n "${__PE}" ]]; then
|
|
||||||
RUN_ENV+=( -e "AMNEZIA_EDITION=${__PE}" )
|
|
||||||
echo "→ AMNEZIA_EDITION из ${INSTALL_DIR}/.amnezia-panel-edition: ${__PE}"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
for __ce_var in COMMUNITY_UPGRADE_URL COMMUNITY_UPGRADE_PITCH; do
|
|
||||||
if [[ -n "${!__ce_var:-}" ]]; then
|
|
||||||
RUN_ENV+=( -e "${__ce_var}=${!__ce_var}" )
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
IP="$(hostname -I 2>/dev/null | awk '{print $1}' || true)"
|
IP="$(hostname -I 2>/dev/null | awk '{print $1}' || true)"
|
||||||
|
|
||||||
|
|||||||
327
server.js
327
server.js
@@ -51,36 +51,6 @@ function resolveUiHidden() {
|
|||||||
|
|
||||||
const UI_HIDDEN = resolveUiHidden();
|
const UI_HIDDEN = resolveUiHidden();
|
||||||
|
|
||||||
const AMNEZIA_EDITION = (process.env.AMNEZIA_EDITION || "pro").trim().toLowerCase();
|
|
||||||
const IS_COMMUNITY = AMNEZIA_EDITION === "community";
|
|
||||||
const COMMUNITY_UPGRADE_URL =
|
|
||||||
process.env.COMMUNITY_UPGRADE_URL?.trim() ||
|
|
||||||
"https://github.com/andrey271192/amnezia_web-PRO";
|
|
||||||
const COMMUNITY_UPGRADE_PITCH =
|
|
||||||
process.env.COMMUNITY_UPGRADE_PITCH?.trim() ||
|
|
||||||
"В PRO: вкл/выкл клиентов, даты и расписание отключений, переименование, удаление, экспорт .conf, каскад, Cloudflare WARP, синхронизация времени хоста. Полная сборка открыта в публичном репозитории amnezia_web-PRO.";
|
|
||||||
|
|
||||||
function editionPayload() {
|
|
||||||
return {
|
|
||||||
tier: IS_COMMUNITY ? "community" : "pro",
|
|
||||||
readOnlyClients: IS_COMMUNITY,
|
|
||||||
upgradeUrl: IS_COMMUNITY ? COMMUNITY_UPGRADE_URL : null,
|
|
||||||
upgradePitch: IS_COMMUNITY ? COMMUNITY_UPGRADE_PITCH : null,
|
|
||||||
showDebugWg: !IS_COMMUNITY,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function effectiveUiHidden() {
|
|
||||||
if (!IS_COMMUNITY) return { ...UI_HIDDEN };
|
|
||||||
return {
|
|
||||||
users: UI_HIDDEN.users,
|
|
||||||
warp: true,
|
|
||||||
cascade: true,
|
|
||||||
mtproto: UI_HIDDEN.mtproto,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function parseProfilesFromEnv() {
|
function parseProfilesFromEnv() {
|
||||||
const raw = process.env.AWG_PROFILES?.trim();
|
const raw = process.env.AWG_PROFILES?.trim();
|
||||||
const fallback = () => {
|
const fallback = () => {
|
||||||
@@ -457,23 +427,6 @@ function requireAuthOrExportToken(req, res, next) {
|
|||||||
requireAuth(req, res, next);
|
requireAuth(req, res, next);
|
||||||
}
|
}
|
||||||
|
|
||||||
function rejectCommunityProOnly(res) {
|
|
||||||
res.status(403).json({
|
|
||||||
error:
|
|
||||||
"Доступно в версии PRO: управление клиентами, экспорт .conf, каскад, Cloudflare WARP и синхронизация времени хоста.",
|
|
||||||
upgradeRequired: true,
|
|
||||||
upgradeUrl: COMMUNITY_UPGRADE_URL,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireProTier(_req, res, next) {
|
|
||||||
if (!IS_COMMUNITY) {
|
|
||||||
next();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
rejectCommunityProOnly(res);
|
|
||||||
}
|
|
||||||
|
|
||||||
function runtimeFromExportRequest(req) {
|
function runtimeFromExportRequest(req) {
|
||||||
const qPid = typeof req.query.profileId === "string" ? req.query.profileId.trim() : "";
|
const qPid = typeof req.query.profileId === "string" ? req.query.profileId.trim() : "";
|
||||||
const bodyPid =
|
const bodyPid =
|
||||||
@@ -2106,9 +2059,6 @@ if (UI_HIDDEN.users || UI_HIDDEN.warp || UI_HIDDEN.cascade || UI_HIDDEN.mtproto)
|
|||||||
`UI_HIDDEN: users=${UI_HIDDEN.users} warp=${UI_HIDDEN.warp} cascade=${UI_HIDDEN.cascade} mtproto=${UI_HIDDEN.mtproto}`,
|
`UI_HIDDEN: users=${UI_HIDDEN.users} warp=${UI_HIDDEN.warp} cascade=${UI_HIDDEN.cascade} mtproto=${UI_HIDDEN.mtproto}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (IS_COMMUNITY) {
|
|
||||||
console.warn(`Редакция community (только просмотр клиентов). PRO: ${COMMUNITY_UPGRADE_URL}`);
|
|
||||||
}
|
|
||||||
app.use(express.json({ limit: "2mb" }));
|
app.use(express.json({ limit: "2mb" }));
|
||||||
|
|
||||||
app.get("/health", (_req, res) => {
|
app.get("/health", (_req, res) => {
|
||||||
@@ -2154,15 +2104,6 @@ app.get("/api/server-time", requireAuth, (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/time-sync-capabilities", requireAuth, (_req, res) => {
|
app.get("/api/time-sync-capabilities", requireAuth, (_req, res) => {
|
||||||
if (IS_COMMUNITY) {
|
|
||||||
res.json({
|
|
||||||
hostTimeSync: false,
|
|
||||||
sshHost: process.env.TIME_SYNC_SSH_HOST?.trim() || "172.17.0.1",
|
|
||||||
serverClockTimeZone: resolveServerClockTimeZone(),
|
|
||||||
communityBlocked: true,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.json({
|
res.json({
|
||||||
hostTimeSync: hostTimeSyncConfigured(),
|
hostTimeSync: hostTimeSyncConfigured(),
|
||||||
sshHost: process.env.TIME_SYNC_SSH_HOST?.trim() || "172.17.0.1",
|
sshHost: process.env.TIME_SYNC_SSH_HOST?.trim() || "172.17.0.1",
|
||||||
@@ -2180,7 +2121,7 @@ function mtprotoLogsTailPayload(req) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.get("/api/mtproto/status", requireAuth, (req, res) => {
|
app.get("/api/mtproto/status", requireAuth, (req, res) => {
|
||||||
if (effectiveUiHidden().mtproto) {
|
if (UI_HIDDEN.mtproto) {
|
||||||
return res.status(403).json({ error: MSG_UI_MTProto_OFF });
|
return res.status(403).json({ error: MSG_UI_MTProto_OFF });
|
||||||
}
|
}
|
||||||
const snap = mtprotoSnapshot(hostFromRequest(req));
|
const snap = mtprotoSnapshot(hostFromRequest(req));
|
||||||
@@ -2196,21 +2137,21 @@ app.get("/api/mtproto/status", requireAuth, (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/mtproto/logs", requireAuth, (req, res) => {
|
app.get("/api/mtproto/logs", requireAuth, (req, res) => {
|
||||||
if (effectiveUiHidden().mtproto) {
|
if (UI_HIDDEN.mtproto) {
|
||||||
return res.status(403).json({ error: MSG_UI_MTProto_OFF });
|
return res.status(403).json({ error: MSG_UI_MTProto_OFF });
|
||||||
}
|
}
|
||||||
res.json(mtprotoLogsTailPayload(req));
|
res.json(mtprotoLogsTailPayload(req));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/mtproto/tail", requireAuth, (req, res) => {
|
app.get("/api/mtproto/tail", requireAuth, (req, res) => {
|
||||||
if (effectiveUiHidden().mtproto) {
|
if (UI_HIDDEN.mtproto) {
|
||||||
return res.status(403).json({ error: MSG_UI_MTProto_OFF });
|
return res.status(403).json({ error: MSG_UI_MTProto_OFF });
|
||||||
}
|
}
|
||||||
res.json(mtprotoLogsTailPayload(req));
|
res.json(mtprotoLogsTailPayload(req));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/mtproto/install", requireAuth, (req, res) => {
|
app.post("/api/mtproto/install", requireAuth, (req, res) => {
|
||||||
if (effectiveUiHidden().mtproto) {
|
if (UI_HIDDEN.mtproto) {
|
||||||
return res.status(403).json({ error: MSG_UI_MTProto_OFF });
|
return res.status(403).json({ error: MSG_UI_MTProto_OFF });
|
||||||
}
|
}
|
||||||
if (!/^[a-zA-Z][a-zA-Z0-9_.-]*$/.test(MTPRO_CONTAINER)) {
|
if (!/^[a-zA-Z][a-zA-Z0-9_.-]*$/.test(MTPRO_CONTAINER)) {
|
||||||
@@ -2306,7 +2247,7 @@ app.post("/api/mtproto/install", requireAuth, (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/mtproto/remove", requireAuth, (_req, res) => {
|
app.post("/api/mtproto/remove", requireAuth, (_req, res) => {
|
||||||
if (effectiveUiHidden().mtproto) {
|
if (UI_HIDDEN.mtproto) {
|
||||||
return res.status(403).json({ error: MSG_UI_MTProto_OFF });
|
return res.status(403).json({ error: MSG_UI_MTProto_OFF });
|
||||||
}
|
}
|
||||||
const r = dockerSpawnSync(["rm", "-f", MTPRO_CONTAINER], 120_000);
|
const r = dockerSpawnSync(["rm", "-f", MTPRO_CONTAINER], 120_000);
|
||||||
@@ -2320,7 +2261,7 @@ app.post("/api/mtproto/remove", requireAuth, (_req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/mtproto/restart", requireAuth, (_req, res) => {
|
app.post("/api/mtproto/restart", requireAuth, (_req, res) => {
|
||||||
if (effectiveUiHidden().mtproto) {
|
if (UI_HIDDEN.mtproto) {
|
||||||
return res.status(403).json({ error: MSG_UI_MTProto_OFF });
|
return res.status(403).json({ error: MSG_UI_MTProto_OFF });
|
||||||
}
|
}
|
||||||
const snap = mtprotoSnapshot();
|
const snap = mtprotoSnapshot();
|
||||||
@@ -2335,7 +2276,7 @@ app.post("/api/mtproto/restart", requireAuth, (_req, res) => {
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/sync-host-time", requireAuth, requireProTier, async (req, res) => {
|
app.post("/api/sync-host-time", requireAuth, async (req, res) => {
|
||||||
if (!hostTimeSyncConfigured()) {
|
if (!hostTimeSyncConfigured()) {
|
||||||
return res.status(503).json({
|
return res.status(503).json({
|
||||||
error:
|
error:
|
||||||
@@ -2367,7 +2308,7 @@ app.post("/api/sync-host-time", requireAuth, requireProTier, async (req, res) =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/warp/host-setup", requireAuth, requireProTier, async (req, res) => {
|
app.post("/api/warp/host-setup", requireAuth, async (req, res) => {
|
||||||
if (UI_HIDDEN.warp) {
|
if (UI_HIDDEN.warp) {
|
||||||
return res.status(403).json({ error: MSG_UI_WARP_OFF });
|
return res.status(403).json({ error: MSG_UI_WARP_OFF });
|
||||||
}
|
}
|
||||||
@@ -2411,6 +2352,202 @@ app.post("/api/warp/host-setup", requireAuth, requireProTier, async (req, res) =
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------- Xray VLESS-Reality ----------
|
||||||
|
// Layout matches the official Amnezia client (xray_*.key + clientsTable), so the app sees the same server.
|
||||||
|
const XRAY_CONTAINER = "amnezia-xray";
|
||||||
|
const XRAY_IMAGE = "amnezia-xray";
|
||||||
|
const XRAY_DIR = "/opt/amnezia/xray";
|
||||||
|
const XRAY_VERSION = "v26.3.27";
|
||||||
|
const XRAY_DOCKERFILE = `FROM alpine:3.20
|
||||||
|
RUN apk add --no-cache curl unzip bash openssl dumb-init && \\
|
||||||
|
curl -fsSL -o /root/xray.zip https://github.com/XTLS/Xray-core/releases/download/${XRAY_VERSION}/Xray-linux-64.zip && \\
|
||||||
|
unzip /root/xray.zip xray -d /usr/bin/ && chmod +x /usr/bin/xray && rm /root/xray.zip && mkdir -p ${XRAY_DIR}
|
||||||
|
ENTRYPOINT ["dumb-init", "/usr/bin/xray", "-config", "${XRAY_DIR}/server.json"]
|
||||||
|
`;
|
||||||
|
|
||||||
|
function xrayExec(script) {
|
||||||
|
return dockerExecStdin(XRAY_CONTAINER, script);
|
||||||
|
}
|
||||||
|
function xrayShellQuote(s) {
|
||||||
|
return `'${String(s).replace(/'/g, `'\\''`)}'`;
|
||||||
|
}
|
||||||
|
async function xrayReadFile(name) {
|
||||||
|
try {
|
||||||
|
return (await xrayExec(`cat ${XRAY_DIR}/${name} 2>/dev/null`)).stdout;
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function xrayWriteFile(name, text) {
|
||||||
|
await execDocker(["exec", "-i", XRAY_CONTAINER, "sh", "-c", `cat > ${XRAY_DIR}/${name}`], text);
|
||||||
|
}
|
||||||
|
function xrayInspect() {
|
||||||
|
const r = dockerSpawnSync(["inspect", "--format", "{{.State.Running}}", XRAY_CONTAINER], 15_000);
|
||||||
|
return { installed: r.code === 0, running: r.stdout.trim() === "true" };
|
||||||
|
}
|
||||||
|
async function xrayState() {
|
||||||
|
const server = JSON.parse((await xrayReadFile("server.json")) || "{}");
|
||||||
|
const ib = (server.inbounds || []).find((i) => i.protocol === "vless");
|
||||||
|
if (!ib) throw new Error("В server.json нет VLESS-входа.");
|
||||||
|
let table = [];
|
||||||
|
try {
|
||||||
|
table = JSON.parse((await xrayReadFile("clientsTable")) || "[]");
|
||||||
|
} catch {}
|
||||||
|
if (!Array.isArray(table)) table = [];
|
||||||
|
return {
|
||||||
|
server,
|
||||||
|
ib,
|
||||||
|
table,
|
||||||
|
pub: (await xrayReadFile("xray_public.key")).trim(),
|
||||||
|
shortId: (await xrayReadFile("xray_short_id.key")).trim() || ib.streamSettings?.realitySettings?.shortIds?.[0] || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async function xraySave(st) {
|
||||||
|
// Enabled = present in server.json; disabled clients live only in clientsTable.
|
||||||
|
st.ib.settings.clients = st.table
|
||||||
|
.filter((r) => !r.userData?.disabled)
|
||||||
|
.map((r) => ({ id: r.clientId, flow: "xtls-rprx-vision" }));
|
||||||
|
await xrayWriteFile("server.json", JSON.stringify(st.server, null, 2));
|
||||||
|
await xrayWriteFile("clientsTable", JSON.stringify(st.table, null, 2));
|
||||||
|
await execDocker(["restart", XRAY_CONTAINER]);
|
||||||
|
}
|
||||||
|
function xrayLink(st, row, host) {
|
||||||
|
const rs = st.ib.streamSettings.realitySettings;
|
||||||
|
const q = new URLSearchParams({
|
||||||
|
type: "tcp",
|
||||||
|
security: "reality",
|
||||||
|
pbk: st.pub,
|
||||||
|
fp: "chrome",
|
||||||
|
sni: rs.serverNames[0],
|
||||||
|
sid: st.shortId,
|
||||||
|
spx: "/",
|
||||||
|
flow: "xtls-rprx-vision",
|
||||||
|
encryption: "none",
|
||||||
|
});
|
||||||
|
const name = encodeURIComponent(row.userData?.clientName || "client");
|
||||||
|
return `vless://${row.clientId}@${host}:${st.ib.port}?${q}#${name}`;
|
||||||
|
}
|
||||||
|
function xrayHost(req) {
|
||||||
|
return configuredEndpointHost() || hostFromRequest(req) || "SERVER_IP";
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get("/api/xray/status", requireAuth, async (req, res) => {
|
||||||
|
const s = xrayInspect();
|
||||||
|
if (!s.running) return res.json({ ...s, clients: [] });
|
||||||
|
try {
|
||||||
|
const st = await xrayState();
|
||||||
|
const host = xrayHost(req);
|
||||||
|
res.json({
|
||||||
|
...s,
|
||||||
|
port: st.ib.port,
|
||||||
|
sni: st.ib.streamSettings?.realitySettings?.serverNames?.[0] || "",
|
||||||
|
clients: st.table.map((r) => ({
|
||||||
|
id: r.clientId,
|
||||||
|
name: r.userData?.clientName || r.clientId,
|
||||||
|
enabled: !r.userData?.disabled,
|
||||||
|
created: r.userData?.creationDate || "",
|
||||||
|
link: xrayLink(st, r, host),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
res.json({ ...s, clients: [], error: String(e.message || e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/xray/install", requireAuth, async (req, res) => {
|
||||||
|
const port = Number.parseInt(String(req.body?.port || "443"), 10);
|
||||||
|
const sni = String(req.body?.sni || "www.google.com").trim();
|
||||||
|
if (!(port > 0 && port < 65536)) return res.status(400).json({ error: "Неверный порт." });
|
||||||
|
if (!/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(sni)) return res.status(400).json({ error: "Неверный сайт-маскировка (SNI)." });
|
||||||
|
if (xrayInspect().installed) return res.status(409).json({ error: "Xray уже установлен. Сначала удалите его." });
|
||||||
|
const busy = dockerSpawnSync(["ps", "--format", "{{.Names}} {{.Ports}}"], 15_000).stdout
|
||||||
|
.split("\n")
|
||||||
|
.find((l) => new RegExp(`:${port}->\\d+/tcp`).test(l));
|
||||||
|
if (busy) return res.status(409).json({ error: `TCP-порт ${port} занят: ${busy.split(" ")[0]}` });
|
||||||
|
try {
|
||||||
|
await execDocker(["build", "-t", XRAY_IMAGE, "-"], XRAY_DOCKERFILE);
|
||||||
|
const kp = (await execDocker(["run", "--rm", "--entrypoint", "/usr/bin/xray", XRAY_IMAGE, "x25519"])).stdout;
|
||||||
|
const priv = (kp.match(/Private\s*key:\s*(\S+)/i) || kp.match(/PrivateKey:\s*(\S+)/i) || [])[1];
|
||||||
|
const pub = (kp.match(/(?:Public\s*key|Password)[^:\n]*:\s*(\S+)/i) || [])[1];
|
||||||
|
if (!priv || !pub) throw new Error(`Не удалось получить ключи x25519: ${kp}`);
|
||||||
|
const shortId = crypto.randomBytes(8).toString("hex");
|
||||||
|
const server = {
|
||||||
|
log: { loglevel: "error" },
|
||||||
|
inbounds: [
|
||||||
|
{
|
||||||
|
port,
|
||||||
|
protocol: "vless",
|
||||||
|
settings: { clients: [], decryption: "none" },
|
||||||
|
streamSettings: {
|
||||||
|
network: "tcp",
|
||||||
|
security: "reality",
|
||||||
|
realitySettings: { dest: `${sni}:443`, serverNames: [sni], privateKey: priv, shortIds: [shortId] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
outbounds: [{ protocol: "freedom" }],
|
||||||
|
};
|
||||||
|
const files = {
|
||||||
|
"server.json": JSON.stringify(server, null, 2),
|
||||||
|
"xray_private.key": `${priv}\n`,
|
||||||
|
"xray_public.key": `${pub}\n`,
|
||||||
|
"xray_short_id.key": `${shortId}\n`,
|
||||||
|
"xray_uuid.key": "\n",
|
||||||
|
clientsTable: "[]",
|
||||||
|
};
|
||||||
|
const script = ["set -e", `mkdir -p /x`]
|
||||||
|
.concat(Object.entries(files).map(([n, t]) => `printf '%s' ${xrayShellQuote(t)} > /x/${n}`))
|
||||||
|
.join("\n");
|
||||||
|
await execDocker(["run", "--rm", "-i", "-v", `${XRAY_DIR}:/x`, "--entrypoint", "sh", XRAY_IMAGE, "-s"], script);
|
||||||
|
await execDocker([
|
||||||
|
"run", "-d", "--restart", "always", "--name", XRAY_CONTAINER,
|
||||||
|
"-p", `${port}:${port}/tcp`, "-v", `${XRAY_DIR}:${XRAY_DIR}`, XRAY_IMAGE,
|
||||||
|
]);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: String(e.message || e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/xray/remove", requireAuth, async (_req, res) => {
|
||||||
|
dockerSpawnSync(["rm", "-f", XRAY_CONTAINER], 60_000);
|
||||||
|
// ponytail: keys and clientsTable stay in /opt/amnezia/xray on the host; a reinstall regenerates them.
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/xray/clients/create", requireAuth, async (req, res) => {
|
||||||
|
const name = String(req.body?.name || "").trim().slice(0, 64);
|
||||||
|
if (!name) return res.status(400).json({ error: "Укажите имя клиента." });
|
||||||
|
try {
|
||||||
|
const st = await xrayState();
|
||||||
|
const row = { clientId: crypto.randomUUID(), userData: { clientName: name, creationDate: new Date().toString() } };
|
||||||
|
st.table.push(row);
|
||||||
|
await xraySave(st);
|
||||||
|
res.json({ ok: true, link: xrayLink(st, row, xrayHost(req)) });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: String(e.message || e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/xray/clients/update", requireAuth, async (req, res) => {
|
||||||
|
const id = String(req.body?.id || "");
|
||||||
|
const action = String(req.body?.action || "");
|
||||||
|
try {
|
||||||
|
const st = await xrayState();
|
||||||
|
const row = st.table.find((r) => r.clientId === id);
|
||||||
|
if (!row) return res.status(404).json({ error: "Клиент не найден." });
|
||||||
|
if (action === "delete") st.table = st.table.filter((r) => r !== row);
|
||||||
|
else if (action === "enable" || action === "disable") {
|
||||||
|
row.userData = { ...row.userData, disabled: action === "disable" };
|
||||||
|
if (!row.userData.disabled) delete row.userData.disabled;
|
||||||
|
} else return res.status(400).json({ error: "Неизвестное действие." });
|
||||||
|
await xraySave(st);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: String(e.message || e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post("/api/login", (req, res) => {
|
app.post("/api/login", (req, res) => {
|
||||||
const pw = req.body?.password;
|
const pw = req.body?.password;
|
||||||
if (typeof pw !== "string" || !pw) {
|
if (typeof pw !== "string" || !pw) {
|
||||||
@@ -2462,7 +2599,7 @@ app.get("/api/settings", requireAuth, (_req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/settings", requireAuth, requireProTier, async (req, res) => {
|
app.post("/api/settings", requireAuth, async (req, res) => {
|
||||||
const raw = String(req.body?.endpointHost ?? "").trim();
|
const raw = String(req.body?.endpointHost ?? "").trim();
|
||||||
let host = "";
|
let host = "";
|
||||||
if (raw) {
|
if (raw) {
|
||||||
@@ -2491,9 +2628,7 @@ app.get("/api/protocols", requireAuth, (req, res) => {
|
|||||||
const rt = runtimeForRequest(req);
|
const rt = runtimeForRequest(req);
|
||||||
const hintSingle =
|
const hintSingle =
|
||||||
getProfiles().length < 2
|
getProfiles().length < 2
|
||||||
? IS_COMMUNITY
|
? "Сейчас один инстанс: при установке не передали AWG_PROFILES или не восстановился снимок. Задайте JSON профилей и запустите install.sh — он сохранится в /root/amnezia-admin.awg-profiles.json."
|
||||||
? "Один инстанс в интерфейсе. Несколько контейнеров и профиль AWG_PROFILES — в полной панели PRO."
|
|
||||||
: "Сейчас один инстанс: при установке не передали AWG_PROFILES или не восстановился снимок. Задайте JSON профилей и запустите install.sh — он сохранится в /root/amnezia-admin.awg-profiles.json."
|
|
||||||
: "";
|
: "";
|
||||||
res.json({
|
res.json({
|
||||||
currentId: rt.profile.id,
|
currentId: rt.profile.id,
|
||||||
@@ -2505,7 +2640,6 @@ app.get("/api/protocols", requireAuth, (req, res) => {
|
|||||||
})),
|
})),
|
||||||
singleProfile: getProfiles().length < 2,
|
singleProfile: getProfiles().length < 2,
|
||||||
profilesPersistHint: hintSingle,
|
profilesPersistHint: hintSingle,
|
||||||
edition: editionPayload(),
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2615,8 +2749,7 @@ app.get("/api/clients", requireAuth, async (req, res) => {
|
|||||||
clients: rows,
|
clients: rows,
|
||||||
wgShow,
|
wgShow,
|
||||||
warp: warpOut,
|
warp: warpOut,
|
||||||
uiHidden: { ...effectiveUiHidden() },
|
uiHidden: { ...UI_HIDDEN },
|
||||||
edition: editionPayload(),
|
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -2624,7 +2757,7 @@ app.get("/api/clients", requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/clients/sync-peers", requireAuth, requireProTier, async (req, res) => {
|
app.post("/api/clients/sync-peers", requireAuth, async (req, res) => {
|
||||||
const rt = runtimeFromExportRequest(req);
|
const rt = runtimeFromExportRequest(req);
|
||||||
try {
|
try {
|
||||||
await rt.backupRemoteFiles();
|
await rt.backupRemoteFiles();
|
||||||
@@ -2718,14 +2851,6 @@ app.get("/api/clients/source-report", requireAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function serveClientConfigExport(req, res) {
|
async function serveClientConfigExport(req, res) {
|
||||||
if (IS_COMMUNITY) {
|
|
||||||
res.status(403).json({
|
|
||||||
error: "Экспорт .conf доступен в версии PRO.",
|
|
||||||
upgradeRequired: true,
|
|
||||||
upgradeUrl: COMMUNITY_UPGRADE_URL,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const tokenOk =
|
const tokenOk =
|
||||||
req.method === "GET" &&
|
req.method === "GET" &&
|
||||||
verifyExportQueryToken(typeof req.query.token === "string" ? req.query.token : "");
|
verifyExportQueryToken(typeof req.query.token === "string" ? req.query.token : "");
|
||||||
@@ -2805,7 +2930,7 @@ app.post("/api/clients/export-config", requireAuth, (req, res) => {
|
|||||||
* Новый клиент для каскада: генерирует ключи, добавляет peer на сервер, сохраняет last_config,
|
* Новый клиент для каскада: генерирует ключи, добавляет peer на сервер, сохраняет last_config,
|
||||||
* отдаёт .conf с Endpoint = endpointHost:endpointPort (ваш промежуточный узел).
|
* отдаёт .conf с Endpoint = endpointHost:endpointPort (ваш промежуточный узел).
|
||||||
*/
|
*/
|
||||||
app.post("/api/clients/create", requireAuth, requireProTier, async (req, res) => {
|
app.post("/api/clients/create", requireAuth, async (req, res) => {
|
||||||
const rt = runtimeFromExportRequest(req);
|
const rt = runtimeFromExportRequest(req);
|
||||||
try {
|
try {
|
||||||
await rt.backupRemoteFiles();
|
await rt.backupRemoteFiles();
|
||||||
@@ -2898,7 +3023,7 @@ AllowedIPs = ${tunnelIp}/32
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/clients/create-cascade", requireAuth, requireProTier, async (req, res) => {
|
app.post("/api/clients/create-cascade", requireAuth, async (req, res) => {
|
||||||
if (UI_HIDDEN.cascade) {
|
if (UI_HIDDEN.cascade) {
|
||||||
return res.status(403).json({ error: MSG_UI_CASCADE_OFF });
|
return res.status(403).json({ error: MSG_UI_CASCADE_OFF });
|
||||||
}
|
}
|
||||||
@@ -3014,7 +3139,7 @@ AllowedIPs = ${tunnelIp}/32
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/clients/import-config", requireAuth, requireProTier, async (req, res) => {
|
app.post("/api/clients/import-config", requireAuth, async (req, res) => {
|
||||||
const rt = runtimeFromExportRequest(req);
|
const rt = runtimeFromExportRequest(req);
|
||||||
try {
|
try {
|
||||||
const raw = req.body?.configText ?? req.body?.backupText ?? req.body?.text ?? "";
|
const raw = req.body?.configText ?? req.body?.backupText ?? req.body?.text ?? "";
|
||||||
@@ -3040,7 +3165,7 @@ app.post("/api/clients/import-config", requireAuth, requireProTier, async (req,
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/warp/start", requireAuth, requireProTier, async (req, res) => {
|
app.post("/api/warp/start", requireAuth, async (req, res) => {
|
||||||
if (UI_HIDDEN.warp) {
|
if (UI_HIDDEN.warp) {
|
||||||
return res.status(403).json({ error: MSG_UI_WARP_OFF });
|
return res.status(403).json({ error: MSG_UI_WARP_OFF });
|
||||||
}
|
}
|
||||||
@@ -3061,7 +3186,7 @@ app.post("/api/warp/start", requireAuth, requireProTier, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/cascade/upstream", requireAuth, requireProTier, async (req, res) => {
|
app.post("/api/cascade/upstream", requireAuth, async (req, res) => {
|
||||||
if (UI_HIDDEN.warp || UI_HIDDEN.cascade) {
|
if (UI_HIDDEN.warp || UI_HIDDEN.cascade) {
|
||||||
return res.status(403).json({ error: MSG_UI_CASCADE_OFF });
|
return res.status(403).json({ error: MSG_UI_CASCADE_OFF });
|
||||||
}
|
}
|
||||||
@@ -3086,7 +3211,7 @@ app.post("/api/cascade/upstream", requireAuth, requireProTier, async (req, res)
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/warp/stop", requireAuth, requireProTier, async (req, res) => {
|
app.post("/api/warp/stop", requireAuth, async (req, res) => {
|
||||||
if (UI_HIDDEN.warp) {
|
if (UI_HIDDEN.warp) {
|
||||||
return res.status(403).json({ error: MSG_UI_WARP_OFF });
|
return res.status(403).json({ error: MSG_UI_WARP_OFF });
|
||||||
}
|
}
|
||||||
@@ -3103,7 +3228,7 @@ app.post("/api/warp/stop", requireAuth, requireProTier, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/warp/routing", requireAuth, requireProTier, async (req, res) => {
|
app.post("/api/warp/routing", requireAuth, async (req, res) => {
|
||||||
if (UI_HIDDEN.warp) {
|
if (UI_HIDDEN.warp) {
|
||||||
return res.status(403).json({ error: MSG_UI_WARP_OFF });
|
return res.status(403).json({ error: MSG_UI_WARP_OFF });
|
||||||
}
|
}
|
||||||
@@ -3142,7 +3267,7 @@ app.post("/api/warp/routing", requireAuth, requireProTier, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/clients/disable", requireAuth, requireProTier, async (req, res) => {
|
app.post("/api/clients/disable", requireAuth, async (req, res) => {
|
||||||
const rt = runtimeFromExportRequest(req);
|
const rt = runtimeFromExportRequest(req);
|
||||||
const clientId = req.body?.clientId;
|
const clientId = req.body?.clientId;
|
||||||
if (!clientId) return res.status(400).json({ error: "clientId required" });
|
if (!clientId) return res.status(400).json({ error: "clientId required" });
|
||||||
@@ -3165,7 +3290,7 @@ app.post("/api/clients/disable", requireAuth, requireProTier, async (req, res) =
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/clients/enable", requireAuth, requireProTier, async (req, res) => {
|
app.post("/api/clients/enable", requireAuth, async (req, res) => {
|
||||||
const rt = runtimeFromExportRequest(req);
|
const rt = runtimeFromExportRequest(req);
|
||||||
const clientId = req.body?.clientId;
|
const clientId = req.body?.clientId;
|
||||||
if (!clientId) return res.status(400).json({ error: "clientId required" });
|
if (!clientId) return res.status(400).json({ error: "clientId required" });
|
||||||
@@ -3215,7 +3340,7 @@ AllowedIPs = ${ips}`;
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/clients/disconnect-date", requireAuth, requireProTier, async (req, res) => {
|
app.post("/api/clients/disconnect-date", requireAuth, async (req, res) => {
|
||||||
const rt = runtimeFromExportRequest(req);
|
const rt = runtimeFromExportRequest(req);
|
||||||
const clientId = req.body?.clientId;
|
const clientId = req.body?.clientId;
|
||||||
if (!clientId) return res.status(400).json({ error: "clientId required" });
|
if (!clientId) return res.status(400).json({ error: "clientId required" });
|
||||||
@@ -3259,7 +3384,7 @@ app.post("/api/clients/disconnect-date", requireAuth, requireProTier, async (req
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/clients/rename", requireAuth, requireProTier, async (req, res) => {
|
app.post("/api/clients/rename", requireAuth, async (req, res) => {
|
||||||
const rt = runtimeFromExportRequest(req);
|
const rt = runtimeFromExportRequest(req);
|
||||||
const clientId = req.body?.clientId;
|
const clientId = req.body?.clientId;
|
||||||
const rawName = req.body?.name ?? req.body?.clientName;
|
const rawName = req.body?.name ?? req.body?.clientName;
|
||||||
@@ -3291,7 +3416,7 @@ app.post("/api/clients/rename", requireAuth, requireProTier, async (req, res) =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/clients/delete", requireAuth, requireProTier, async (req, res) => {
|
app.post("/api/clients/delete", requireAuth, async (req, res) => {
|
||||||
const rt = runtimeFromExportRequest(req);
|
const rt = runtimeFromExportRequest(req);
|
||||||
const clientId = req.body?.clientId;
|
const clientId = req.body?.clientId;
|
||||||
if (!clientId) return res.status(400).json({ error: "clientId required" });
|
if (!clientId) return res.status(400).json({ error: "clientId required" });
|
||||||
@@ -3407,7 +3532,7 @@ app.get("/api/instances", requireAuth, async (_req, res) => {
|
|||||||
res.json({ instances: items, variants: INSTANCE_VARIANTS });
|
res.json({ instances: items, variants: INSTANCE_VARIANTS });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/instances/create", requireAuth, requireProTier, async (req, res) => {
|
app.post("/api/instances/create", requireAuth, async (req, res) => {
|
||||||
const variant = String(req.body?.variant || "").trim();
|
const variant = String(req.body?.variant || "").trim();
|
||||||
const port = Number(req.body?.port);
|
const port = Number(req.body?.port);
|
||||||
if (!INSTANCE_VARIANTS[variant]) return res.status(400).json({ error: "Неизвестный вариант протокола." });
|
if (!INSTANCE_VARIANTS[variant]) return res.status(400).json({ error: "Неизвестный вариант протокола." });
|
||||||
@@ -3437,7 +3562,7 @@ app.post("/api/instances/create", requireAuth, requireProTier, async (req, res)
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/instances/delete", requireAuth, requireProTier, async (req, res) => {
|
app.post("/api/instances/delete", requireAuth, async (req, res) => {
|
||||||
const id = String(req.body?.id || "").trim();
|
const id = String(req.body?.id || "").trim();
|
||||||
const list = loadManagedProfiles();
|
const list = loadManagedProfiles();
|
||||||
const found = list.find((p) => p.id === id);
|
const found = list.find((p) => p.id === id);
|
||||||
@@ -3458,7 +3583,7 @@ async function resolveProfileContainer(id) {
|
|||||||
catch { return prof.container || null; }
|
catch { return prof.container || null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
app.post("/api/instances/stop", requireAuth, requireProTier, async (req, res) => {
|
app.post("/api/instances/stop", requireAuth, async (req, res) => {
|
||||||
const id = String(req.body?.id || "").trim();
|
const id = String(req.body?.id || "").trim();
|
||||||
const container = await resolveProfileContainer(id);
|
const container = await resolveProfileContainer(id);
|
||||||
if (!container) return res.status(404).json({ error: "Инстанс не найден." });
|
if (!container) return res.status(404).json({ error: "Инстанс не найден." });
|
||||||
@@ -3466,7 +3591,7 @@ app.post("/api/instances/stop", requireAuth, requireProTier, async (req, res) =>
|
|||||||
catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/instances/start", requireAuth, requireProTier, async (req, res) => {
|
app.post("/api/instances/start", requireAuth, async (req, res) => {
|
||||||
const id = String(req.body?.id || "").trim();
|
const id = String(req.body?.id || "").trim();
|
||||||
const container = await resolveProfileContainer(id);
|
const container = await resolveProfileContainer(id);
|
||||||
if (!container) return res.status(404).json({ error: "Инстанс не найден." });
|
if (!container) return res.status(404).json({ error: "Инстанс не найден." });
|
||||||
@@ -3493,13 +3618,9 @@ app.listen(PORT, "0.0.0.0", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
if (!IS_COMMUNITY) {
|
|
||||||
processAllScheduledDisconnects().catch((e) => console.error("scheduleDisconnect:", e));
|
processAllScheduledDisconnects().catch((e) => console.error("scheduleDisconnect:", e));
|
||||||
}
|
|
||||||
}, SCHEDULER_MS);
|
}, SCHEDULER_MS);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!IS_COMMUNITY) {
|
|
||||||
processAllScheduledDisconnects().catch((e) => console.error("scheduleDisconnect:", e));
|
processAllScheduledDisconnects().catch((e) => console.error("scheduleDisconnect:", e));
|
||||||
}
|
|
||||||
}, 4000);
|
}, 4000);
|
||||||
|
|||||||
Reference in New Issue
Block a user