mirror of
https://github.com/andrey271192/amnezia_web-PRO.git
synced 2026-09-20 14:42:00 +00:00
feat: split community (read-only) vs PRO panel editions
Add AMNEZIA_EDITION=community with API/UI locks for client mutations, export, cascade, WARP and host time sync; banner + Boosty CTA. Install passes edition from AMNEZIA_EDITION env or .amnezia-panel-edition. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,6 +6,12 @@
|
|||||||
|
|
||||||
**Безопасность:** контейнер с монтированием `docker.sock` эквивалентен root на хосте — используйте сложный пароль и по возможности ограничьте доступ по IP или TLS.
|
**Безопасность:** контейнер с монтированием `docker.sock` эквивалентен root на хосте — используйте сложный пароль и по возможности ограничьте доступ по IP или TLS.
|
||||||
|
|
||||||
|
### Редакции: открытая база и PRO
|
||||||
|
|
||||||
|
- **[amnezia_web](https://github.com/andrey271192/amnezia_web)** — открытый репозиторий **базовой** панели: **только просмотр** клиентов AmneziaWG и статусов. Нет включения/выключения peer в туннеле, правки дат отключения, переименования, удаления, экспорта `.conf`, блока «Новый клиент под каскад», Cloudflare WARP и синхронизации времени хоста по SSH. Редакция задаётся файлом **`.amnezia-panel-edition`** со значением `community` (его подставляет установщик этого форка) или переменной **`AMNEZIA_EDITION=community`**. Текст и кнопка «Разблокировать PRO» используют **`COMMUNITY_UPGRADE_URL`** и **`COMMUNITY_UPGRADE_PITCH`** (по умолчанию — Boosty); полный код и приватный репозиторий **amnezia_web-PRO** подключаются подписчикам отдельно.
|
||||||
|
|
||||||
|
- **Этот репозиторий (`amnezia_web-PRO`)** — **полная** панель (редакция **pro** по умолчанию): все функции из README ниже активны, если не задана **`AMNEZIA_EDITION=community`**.
|
||||||
|
|
||||||
## Что открывается по какому порту
|
## Что открывается по какому порту
|
||||||
|
|
||||||
| Адрес | Назначение |
|
| Адрес | Назначение |
|
||||||
|
|||||||
@@ -60,6 +60,68 @@ let warpSshPendingCmd = null;
|
|||||||
/** Какие панели скрыты настройкой сервера (`UI_HIDE_SECTIONS`). */
|
/** Какие панели скрыты настройкой сервера (`UI_HIDE_SECTIONS`). */
|
||||||
let uiHidden = { users: false, warp: false, cascade: false };
|
let uiHidden = { users: false, warp: false, cascade: 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 subEl = document.querySelector(".top .sub");
|
||||||
|
if (subEl) {
|
||||||
|
if (editionState.tier === "community") {
|
||||||
|
subEl.textContent =
|
||||||
|
"Базовая панель amnezia_web: только просмотр клиентов AmneziaWG и статусов. Управление туннелем, экспорт .conf, каскад, Cloudflare WARP и синхронизация времени хоста — в версии 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://boosty.to/andrey27/donate";
|
||||||
|
cta.textContent = "Разблокировать PRO (Boosty)";
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
function applyUiHiddenFromPayload(data) {
|
function applyUiHiddenFromPayload(data) {
|
||||||
const u = data?.uiHidden;
|
const u = data?.uiHidden;
|
||||||
if (u && typeof u === "object") {
|
if (u && typeof u === "object") {
|
||||||
@@ -70,7 +132,7 @@ function applyUiHiddenFromPayload(data) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (usersPanel) usersPanel.hidden = uiHidden.users;
|
if (usersPanel) usersPanel.hidden = uiHidden.users;
|
||||||
if (wgRawDetails) wgRawDetails.hidden = uiHidden.users;
|
if (wgRawDetails) wgRawDetails.hidden = uiHidden.users || !editionState.showDebugWg;
|
||||||
if (cascadePanel) cascadePanel.hidden = uiHidden.cascade;
|
if (cascadePanel) cascadePanel.hidden = uiHidden.cascade;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,6 +351,7 @@ 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) {
|
||||||
@@ -517,6 +580,14 @@ 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}. Пароль не сохраняется.`
|
||||||
@@ -609,6 +680,7 @@ 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(
|
||||||
@@ -628,6 +700,16 @@ 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");
|
||||||
@@ -647,16 +729,25 @@ function renderRows(clients) {
|
|||||||
offTd.className = "date-cell";
|
offTd.className = "date-cell";
|
||||||
const dateLine = document.createElement("div");
|
const dateLine = document.createElement("div");
|
||||||
dateLine.textContent = formatLastDisconnect(c);
|
dateLine.textContent = formatLastDisconnect(c);
|
||||||
|
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.append(dateLine, 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(btn("Выключить", "btn small ghost", () => openDisableDialog(c)));
|
actTd.appendChild(btn("Выключить", "btn small ghost", () => openDisableDialog(c)));
|
||||||
} else {
|
} else {
|
||||||
@@ -683,6 +774,7 @@ function renderRows(clients) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
actTd.appendChild(btn("Удалить", "btn small warn", () => confirmDelete(c.name, c.clientId)));
|
actTd.appendChild(btn("Удалить", "btn small warn", () => 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);
|
||||||
@@ -1059,6 +1151,7 @@ async function loadClients() {
|
|||||||
try {
|
try {
|
||||||
setStatus("Загрузка…", false);
|
setStatus("Загрузка…", false);
|
||||||
const data = await api("/api/clients");
|
const data = await api("/api/clients");
|
||||||
|
applyEditionPayload(data);
|
||||||
applyUiHiddenFromPayload(data);
|
applyUiHiddenFromPayload(data);
|
||||||
const pref = data.profileLabel ? `${data.profileLabel} · ` : "";
|
const pref = data.profileLabel ? `${data.profileLabel} · ` : "";
|
||||||
if (uiHidden.users) {
|
if (uiHidden.users) {
|
||||||
|
|||||||
@@ -51,6 +51,8 @@
|
|||||||
</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>
|
||||||
<div id="proto-switch" class="proto-switch hidden">
|
<div id="proto-switch" class="proto-switch hidden">
|
||||||
|
|||||||
@@ -916,3 +916,37 @@ tr:last-child td {
|
|||||||
max-height: 220px;
|
max-height: 220px;
|
||||||
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edition-banner-cta {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -193,6 +193,21 @@ elif [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "1" ]] || [[ "${ALLOW_DEFAULT_PASSWORD:
|
|||||||
RUN_ENV+=( -e "ALLOW_DEFAULT_PASSWORD=1" )
|
RUN_ENV+=( -e "ALLOW_DEFAULT_PASSWORD=1" )
|
||||||
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)"
|
||||||
|
|
||||||
docker run -d --name "${CONTAINER_NAME}" --restart unless-stopped \
|
docker run -d --name "${CONTAINER_NAME}" --restart unless-stopped \
|
||||||
|
|||||||
105
server.js
105
server.js
@@ -38,6 +38,33 @@ 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://boosty.to/andrey27/donate";
|
||||||
|
const COMMUNITY_UPGRADE_PITCH =
|
||||||
|
process.env.COMMUNITY_UPGRADE_PITCH?.trim() ||
|
||||||
|
"В PRO: вкл/выкл клиентов, даты и расписание отключений, переименование, удаление, экспорт .conf, каскад, Cloudflare WARP, синхронизация времени хоста. Полная сборка — приватный репозиторий amnezia_web-PRO; доступ по подписке Boosty.";
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function parseProfilesFromEnv() {
|
function parseProfilesFromEnv() {
|
||||||
const raw = process.env.AWG_PROFILES?.trim();
|
const raw = process.env.AWG_PROFILES?.trim();
|
||||||
const fallback = () => {
|
const fallback = () => {
|
||||||
@@ -311,6 +338,23 @@ 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 =
|
||||||
@@ -1316,6 +1360,9 @@ if (UI_HIDDEN.users || UI_HIDDEN.warp || UI_HIDDEN.cascade) {
|
|||||||
`UI_HIDDEN: users=${UI_HIDDEN.users} warp=${UI_HIDDEN.warp} cascade=${UI_HIDDEN.cascade}`,
|
`UI_HIDDEN: users=${UI_HIDDEN.users} warp=${UI_HIDDEN.warp} cascade=${UI_HIDDEN.cascade}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (IS_COMMUNITY) {
|
||||||
|
console.warn(`Редакция community (только просмотр клиентов). PRO: ${COMMUNITY_UPGRADE_URL}`);
|
||||||
|
}
|
||||||
app.use(express.json({ limit: "512kb" }));
|
app.use(express.json({ limit: "512kb" }));
|
||||||
|
|
||||||
app.get("/health", (_req, res) => {
|
app.get("/health", (_req, res) => {
|
||||||
@@ -1361,6 +1408,15 @@ 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",
|
||||||
@@ -1368,7 +1424,7 @@ app.get("/api/time-sync-capabilities", requireAuth, (_req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/sync-host-time", requireAuth, async (req, res) => {
|
app.post("/api/sync-host-time", requireAuth, requireProTier, async (req, res) => {
|
||||||
if (!hostTimeSyncConfigured()) {
|
if (!hostTimeSyncConfigured()) {
|
||||||
return res.status(503).json({
|
return res.status(503).json({
|
||||||
error:
|
error:
|
||||||
@@ -1400,7 +1456,7 @@ app.post("/api/sync-host-time", requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/warp/host-setup", requireAuth, async (req, res) => {
|
app.post("/api/warp/host-setup", requireAuth, requireProTier, 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 });
|
||||||
}
|
}
|
||||||
@@ -1488,6 +1544,12 @@ app.post("/api/change-password", requireAuth, (req, res) => {
|
|||||||
|
|
||||||
app.get("/api/protocols", requireAuth, (req, res) => {
|
app.get("/api/protocols", requireAuth, (req, res) => {
|
||||||
const rt = runtimeForRequest(req);
|
const rt = runtimeForRequest(req);
|
||||||
|
const hintSingle =
|
||||||
|
PROFILES.length < 2
|
||||||
|
? IS_COMMUNITY
|
||||||
|
? "Один инстанс в интерфейсе. Несколько контейнеров и профиль 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,
|
||||||
currentLabel: rt.profile.label,
|
currentLabel: rt.profile.label,
|
||||||
@@ -1497,10 +1559,8 @@ app.get("/api/protocols", requireAuth, (req, res) => {
|
|||||||
container: p.container,
|
container: p.container,
|
||||||
})),
|
})),
|
||||||
singleProfile: PROFILES.length < 2,
|
singleProfile: PROFILES.length < 2,
|
||||||
profilesPersistHint:
|
profilesPersistHint: hintSingle,
|
||||||
PROFILES.length < 2
|
edition: editionPayload(),
|
||||||
? "Сейчас один инстанс: при установке не передали AWG_PROFILES или не восстановился снимок. Задайте JSON профилей и запустите install.sh — он сохранится в /root/amnezia-admin.awg-profiles.json."
|
|
||||||
: "",
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1577,7 +1637,8 @@ app.get("/api/clients", requireAuth, async (req, res) => {
|
|||||||
clients: rows,
|
clients: rows,
|
||||||
wgShow,
|
wgShow,
|
||||||
warp: warpOut,
|
warp: warpOut,
|
||||||
uiHidden: { ...UI_HIDDEN },
|
uiHidden: { ...effectiveUiHidden() },
|
||||||
|
edition: editionPayload(),
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -1586,6 +1647,14 @@ app.get("/api/clients", 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 : "");
|
||||||
@@ -1665,7 +1734,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-cascade", requireAuth, async (req, res) => {
|
app.post("/api/clients/create-cascade", requireAuth, requireProTier, 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 });
|
||||||
}
|
}
|
||||||
@@ -1781,7 +1850,7 @@ AllowedIPs = ${tunnelIp}/32
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/warp/start", requireAuth, async (req, res) => {
|
app.post("/api/warp/start", requireAuth, requireProTier, 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 });
|
||||||
}
|
}
|
||||||
@@ -1802,7 +1871,7 @@ app.post("/api/warp/start", requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/warp/stop", requireAuth, async (req, res) => {
|
app.post("/api/warp/stop", requireAuth, requireProTier, 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 });
|
||||||
}
|
}
|
||||||
@@ -1819,7 +1888,7 @@ app.post("/api/warp/stop", requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/warp/routing", requireAuth, async (req, res) => {
|
app.post("/api/warp/routing", requireAuth, requireProTier, 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 });
|
||||||
}
|
}
|
||||||
@@ -1858,7 +1927,7 @@ app.post("/api/warp/routing", requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/clients/disable", requireAuth, async (req, res) => {
|
app.post("/api/clients/disable", requireAuth, requireProTier, async (req, res) => {
|
||||||
const rt = runtimeForRequest(req);
|
const rt = runtimeForRequest(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" });
|
||||||
@@ -1881,7 +1950,7 @@ app.post("/api/clients/disable", requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/clients/enable", requireAuth, async (req, res) => {
|
app.post("/api/clients/enable", requireAuth, requireProTier, async (req, res) => {
|
||||||
const rt = runtimeForRequest(req);
|
const rt = runtimeForRequest(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" });
|
||||||
@@ -1931,7 +2000,7 @@ AllowedIPs = ${ips}`;
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/clients/disconnect-date", requireAuth, async (req, res) => {
|
app.post("/api/clients/disconnect-date", requireAuth, requireProTier, async (req, res) => {
|
||||||
const rt = runtimeForRequest(req);
|
const rt = runtimeForRequest(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" });
|
||||||
@@ -1971,7 +2040,7 @@ app.post("/api/clients/disconnect-date", requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/clients/rename", requireAuth, async (req, res) => {
|
app.post("/api/clients/rename", requireAuth, requireProTier, async (req, res) => {
|
||||||
const rt = runtimeForRequest(req);
|
const rt = runtimeForRequest(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;
|
||||||
@@ -1998,7 +2067,7 @@ app.post("/api/clients/rename", requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/clients/delete", requireAuth, async (req, res) => {
|
app.post("/api/clients/delete", requireAuth, requireProTier, async (req, res) => {
|
||||||
const rt = runtimeForRequest(req);
|
const rt = runtimeForRequest(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" });
|
||||||
@@ -2045,9 +2114,13 @@ 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