v1.3.0: AWG 3.1/3.0/2.0/1.x/WireGuard from the panel, real cascade, server domain, light/dark theme

- client .conf: only obfuscation keys present on the server (AWG 1.x got S3/S4/I1 -> no traffic)
- install.sh no longer puts panel-managed containers into AWG_PROFILES (wrong instance on switch)
- UDP port busy check via docker instead of ss inside the panel container
- default profile never hijacks a managed container

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Бобырев Андрей
2026-10-23 00:42:16 +03:00
parent 6e05a366c9
commit 75345ebc03
8 changed files with 364 additions and 126 deletions

View File

@@ -6,6 +6,24 @@
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. 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.
## Что нового в 1.3.0
- **Установка протоколов прямо с сайта** (раздел «Протоколы / инстансы»), несколько версий рядом на разных портах:
| Вариант | Для кого |
|---|---|
| AmneziaWG 3.1 | новейший: защита заголовков, случайные хвосты; свежее приложение Amnezia |
| AmneziaWG 3.0 | HeaderProtectionKey, паддинг |
| AmneziaWG 2.0 | рекомендуемый по умолчанию (S3/S4) |
| AmneziaWG 1.x | роутеры Keenetic/OpenWrt и старые приложения (без S3/S4) |
| WireGuard (Legacy) | любой WG-клиент, без обфускации |
- **Исправлено:** конфиг клиента для AWG 1.x получал лишние S3/S4/I1 — рукопожатие было, интернета нет. Теперь в .conf попадают только параметры, которые есть на сервере.
- **Исправлено:** при обновлении установщик записывал контейнеры панели в `AWG_PROFILES`, и переключение инстанса создавало клиента не там.
- **Исправлено:** проверка занятого UDP-порта смотрела внутрь контейнера панели и ничего не находила.
- **Домен сервера** — поле «Домен или IP сервера в конфигах клиентов»: подставляется в Endpoint всех выгрузок, домен проверяется на резолв.
- **Настоящий каскад** — в разделе «Cloudflare WARP / каскад» вставьте клиентский .conf другого сервера: отмеченные клиенты выходят в интернет через него (переживает перезапуск контейнера).
- **Светлая / тёмная тема** — кнопка «◐ Тема» в шапке (по умолчанию как в системе).
- Старые установки не затрагиваются: контейнеры, поставленные приложением Amnezia, остаются как были.
## Что открывается по какому порту ## Что открывается по какому порту
| Адрес | Назначение | | Адрес | Назначение |

View File

@@ -1,6 +1,6 @@
{ {
"name": "amnezia-admin", "name": "amnezia-admin",
"version": "1.2.39", "version": "1.3.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",

View File

@@ -629,6 +629,7 @@ loginForm.addEventListener("submit", async (ev) => {
await loadTimeSyncCaps(); await loadTimeSyncCaps();
await loadClients(); await loadClients();
await loadInstances(); await loadInstances();
await loadSettings();
} catch (e) { } catch (e) {
loginError.textContent = String(e.message || e); loginError.textContent = String(e.message || e);
} }
@@ -647,6 +648,7 @@ logoutBtn.addEventListener("click", async () => {
refreshBtn.addEventListener("click", () => { refreshBtn.addEventListener("click", () => {
loadClients(); loadClients();
loadInstances(); loadInstances();
loadSettings();
}); });
const cascadeForm = document.querySelector("#cascade-form"); const cascadeForm = document.querySelector("#cascade-form");
@@ -1730,6 +1732,7 @@ async function boot() {
await loadTimeSyncCaps(); await loadTimeSyncCaps();
await loadClients(); await loadClients();
await loadInstances(); await loadInstances();
await loadSettings();
} else { } else {
showLogin(); showLogin();
loginPassword.focus(); loginPassword.focus();
@@ -1738,3 +1741,50 @@ async function boot() {
initMtprotoLinkCopy(); initMtprotoLinkCopy();
boot(); boot();
// ── Тема, адрес сервера, вышестоящий сервер каскада ──
document.querySelector("#theme-toggle")?.addEventListener("click", () => {
const next = document.documentElement.dataset.theme === "light" ? "dark" : "light";
document.documentElement.dataset.theme = next;
try { localStorage.setItem("theme", next); } catch { /* private mode */ }
});
async function loadSettings() {
const input = document.querySelector("#settings-endpoint");
const hint = document.querySelector("#settings-hint");
if (!input) return;
try {
const st = await api("/api/settings");
input.value = st.endpointHost || "";
if (hint) hint.textContent = `Сейчас в конфигах клиентов: ${st.effectiveEndpoint || "адрес из браузера"}. Новый адрес действует на все выгрузки .conf, в том числе старых клиентов.`;
} catch { /* старый сервер без /api/settings */ }
}
document.querySelector("#settings-form")?.addEventListener("submit", async (ev) => {
ev.preventDefault();
const endpointHost = document.querySelector("#settings-endpoint")?.value.trim() || "";
try {
const r = await api("/api/settings", { method: "POST", body: JSON.stringify({ endpointHost }) });
setStatus(r.resolved ? `Адрес сохранён: ${endpointHost} → ${r.resolved.join(", ")}` : "Адрес сохранён.", false);
await loadSettings();
} catch (e) {
setStatus(String(e.message || e), true);
}
});
document.querySelector("#upstream-form")?.addEventListener("submit", async (ev) => {
ev.preventDefault();
const configText = document.querySelector("#upstream-conf")?.value || "";
const b = ev.target.querySelector("button[type=submit]");
if (b) b.disabled = true;
try {
setStatus("Подключаю вышестоящий сервер…", false);
const r = await api("/api/cascade/upstream", { method: "POST", body: JSON.stringify({ configText }) });
setStatus(r.handshake ? "Каскад подключён: рукопожатие есть. Отметьте клиентов ниже и нажмите «Применить»." : "Туннель поднят, но рукопожатия пока нет — проверьте Endpoint и UDP-порт вышестоящего сервера.", !r.handshake);
await loadClients();
} catch (e) {
setStatus(String(e.message || e), true);
} finally {
if (b) b.disabled = false;
}
});

View File

@@ -7,7 +7,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<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">
<link rel="stylesheet" href="/styles.css?v=1.2.28"> <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">
</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="Поддержка и донат">
@@ -58,6 +59,7 @@
</div> </div>
<div class="token-box"> <div class="token-box">
<div class="session-actions"> <div class="session-actions">
<button type="button" id="theme-toggle" class="btn ghost full" title="Светлая / тёмная тема">◐ Тема</button>
<button type="button" id="logout" class="btn ghost full">Выйти</button> <button type="button" id="logout" class="btn ghost full">Выйти</button>
</div> </div>
<details class="pw-change"> <details class="pw-change">
@@ -121,7 +123,7 @@
<summary class="fold-summary"> <summary class="fold-summary">
<span class="fold-arrow" aria-hidden="true"></span> <span class="fold-arrow" aria-hidden="true"></span>
<span class="fold-titles"> <span class="fold-titles">
<span class="fold-h">Cloudflare WARP</span> <span class="fold-h">Cloudflare WARP / каскад</span>
<span class="muted fold-meta" id="warp-status-line"></span> <span class="muted fold-meta" id="warp-status-line"></span>
</span> </span>
</summary> </summary>
@@ -132,6 +134,11 @@
Один раз на хосте VPS под root: в каталоге с репозиторием выполните <code class="inline">bash scripts/warp-amnezia.sh install</code>. Один раз на хосте VPS под root: в каталоге с репозиторием выполните <code class="inline">bash scripts/warp-amnezia.sh install</code>.
Затем отметьте клиентов ниже и нажмите «Применить» (контейнер AWG перезапустится). Установить или удалить WARP с хоста можно кнопками ниже (пароль root по SSH — как при синхронизации времени). Убрать вручную: <code class="inline">bash scripts/warp-amnezia.sh uninstall</code> — см. README. Затем отметьте клиентов ниже и нажмите «Применить» (контейнер AWG перезапустится). Установить или удалить WARP с хоста можно кнопками ниже (пароль root по SSH — как при синхронизации времени). Убрать вручную: <code class="inline">bash scripts/warp-amnezia.sh uninstall</code> — см. README.
</p> </p>
<form id="upstream-form" class="cascade-form">
<label for="upstream-conf"><strong>Каскад через другой сервер</strong> — вставьте клиентский .conf (AmneziaWG или WireGuard) другого сервера. Он станет выходом вместо WARP: отмеченные ниже клиенты пойдут в интернет через тот сервер.</label>
<textarea id="upstream-conf" rows="6" placeholder="[Interface]&#10;PrivateKey = …&#10;[Peer]&#10;Endpoint = …" autocomplete="off"></textarea>
<button type="submit" class="btn primary">Подключить вышестоящий сервер</button>
</form>
<div id="warp-actions" class="warp-actions"></div> <div id="warp-actions" class="warp-actions"></div>
<div id="warp-client-list" class="warp-client-list"></div> <div id="warp-client-list" class="warp-client-list"></div>
<details class="raw warp-raw"> <details class="raw warp-raw">
@@ -198,8 +205,10 @@
<label for="instance-variant">Протокол</label> <label for="instance-variant">Протокол</label>
<select id="instance-variant" class="form-select"> <select id="instance-variant" class="form-select">
<option value="awg2">AmneziaWG 2.0 (рекомендуется)</option> <option value="awg2">AmneziaWG 2.0 (рекомендуется)</option>
<option value="awg">AmneziaWG</option> <option value="awg31">AmneziaWG 3.1 — новейший, свежее приложение Amnezia</option>
<option value="legacy">AmneziaWG Legacy</option> <option value="awg3">AmneziaWG 3.0</option>
<option value="awg">AmneziaWG 1.x — роутеры Keenetic/OpenWrt, старые приложения</option>
<option value="legacy">WireGuard (Legacy) — любой WG-клиент, без обфускации</option>
</select> </select>
</div> </div>
<div class="instance-form-row"> <div class="instance-form-row">
@@ -208,7 +217,15 @@
</div> </div>
<button type="submit" class="btn primary">+ Развернуть инстанс</button> <button type="submit" class="btn primary">+ Развернуть инстанс</button>
</form> </form>
<p class="muted instance-hint">Создаёт серверный контейнер AmneziaWG из публичного образа, генерирует ключи и NAT. Затем в «Пользователях» выберите инстанс и создавайте клиентов.</p> <p class="muted instance-hint">Создаёт серверный контейнер AmneziaWG из публичного образа, генерирует ключи и NAT. Затем в «Пользователях» выберите инстанс и создавайте клиентов. Можно держать несколько версий рядом на разных портах: 3.1/2.0 — для телефонов и ПК, 1.x или WireGuard — для роутеров. Не забудьте открыть UDP-порт в фаерволе хостинга.</p>
<form id="settings-form" class="instance-form">
<div class="instance-form-row">
<label for="settings-endpoint">Домен или IP сервера в конфигах клиентов</label>
<input id="settings-endpoint" type="text" placeholder="vpn.example.com — пусто: IP из установки" autocomplete="off">
</div>
<button type="submit" class="btn primary">Сохранить адрес</button>
</form>
<p id="settings-hint" class="muted instance-hint"></p>
</div> </div>
</details> </details>
@@ -406,6 +423,6 @@
</div> </div>
</dialog> </dialog>
<script src="/app.js?v=1.2.28" type="module"></script> <script src="/app.js?v=1.3.0" type="module"></script>
</body> </body>
</html> </html>

View File

@@ -8,6 +8,10 @@
--danger: #fb7185; --danger: #fb7185;
--ok: #4ade80; --ok: #4ade80;
--shadow: 0 24px 80px rgba(0, 0, 0, 0.45); --shadow: 0 24px 80px rgba(0, 0, 0, 0.45);
--field: #0a0f16;
--text-soft: #cbd5f5;
--overlay: rgba(10, 15, 22, 0.92);
color-scheme: dark;
font-family: "DM Sans", system-ui, sans-serif; font-family: "DM Sans", system-ui, sans-serif;
} }
@@ -15,6 +19,28 @@
box-sizing: border-box; box-sizing: border-box;
} }
/* Светлая тема: те же переменные, переключатель в шапке (localStorage "theme"). */
html[data-theme="light"] {
--bg: #f4f6fb;
--card: #ffffff;
--line: rgba(15, 23, 42, 0.12);
--text: #0f172a;
--muted: #526077;
--accent: #0369a1;
--danger: #be123c;
--ok: #15803d;
--shadow: 0 12px 40px rgba(15, 23, 42, 0.12);
--field: #ffffff;
--text-soft: #1e293b;
--overlay: rgba(255, 255, 255, 0.94);
color-scheme: light;
}
html[data-theme="light"] .panel-promo-strip {
background: linear-gradient(90deg, #e0f2fe, #eef2ff);
border-bottom-color: rgba(3, 105, 161, 0.25);
}
html[data-theme="light"] .panel-promo-a { color: #92400e; }
body.page { body.page {
margin: 0; margin: 0;
background: radial-gradient(circle at 20% 20%, rgba(125, 211, 252, 0.08), transparent 35%), background: radial-gradient(circle at 20% 20%, rgba(125, 211, 252, 0.08), transparent 35%),
@@ -123,7 +149,7 @@ h1 {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 14px; border-radius: 14px;
padding: 1rem; padding: 1rem;
background: #0c121b; background: var(--field);
} }
.token-box label { .token-box label {
@@ -191,7 +217,7 @@ h1 {
padding: 0.65rem 0.75rem; padding: 0.65rem 0.75rem;
border-radius: 10px; border-radius: 10px;
border: 1px solid var(--line); border: 1px solid var(--line);
background: #0a0f16; background: var(--field);
color: var(--text); color: var(--text);
font: inherit; font: inherit;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
@@ -255,7 +281,7 @@ h1 {
padding: 0.55rem 0.65rem; padding: 0.55rem 0.65rem;
border-radius: 10px; border-radius: 10px;
border: 1px solid var(--line); border: 1px solid var(--line);
background: #0a0f16; background: var(--field);
color: var(--text); color: var(--text);
font: inherit; font: inherit;
} }
@@ -265,7 +291,7 @@ h1 {
padding: 0.65rem 0.75rem; padding: 0.65rem 0.75rem;
border-radius: 10px; border-radius: 10px;
border: 1px solid var(--line); border: 1px solid var(--line);
background: #0a0f16; background: var(--field);
color: var(--text); color: var(--text);
font-family: "JetBrains Mono", ui-monospace, monospace; font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 0.85rem; font-size: 0.85rem;
@@ -294,7 +320,7 @@ h1 {
padding: 0.45rem 0.55rem; padding: 0.45rem 0.55rem;
border-radius: 10px; border-radius: 10px;
border: 1px solid var(--line); border: 1px solid var(--line);
background: #0a0f16; background: var(--field);
color: var(--text); color: var(--text);
font: inherit; font: inherit;
font-size: 0.85rem; font-size: 0.85rem;
@@ -407,7 +433,7 @@ h1 {
padding: 0.45rem 0.55rem; padding: 0.45rem 0.55rem;
border-radius: 10px; border-radius: 10px;
border: 1px solid var(--line); border: 1px solid var(--line);
background: #0a0f16; background: var(--field);
color: var(--text); color: var(--text);
font: inherit; font: inherit;
font-size: 0.85rem; font-size: 0.85rem;
@@ -715,7 +741,7 @@ tr:last-child td {
.ip { .ip {
font-family: "JetBrains Mono", ui-monospace, monospace; font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 0.85rem; font-size: 0.85rem;
color: #dbeafe; color: var(--text-soft);
} }
.badge { .badge {
@@ -736,7 +762,7 @@ tr:last-child td {
.badge.off { .badge.off {
border-color: rgba(148, 163, 184, 0.35); border-color: rgba(148, 163, 184, 0.35);
color: #e2e8f0; color: var(--text-soft);
background: rgba(148, 163, 184, 0.06); background: rgba(148, 163, 184, 0.06);
} }
@@ -833,11 +859,11 @@ tr:last-child td {
.cascade-snippet { .cascade-snippet {
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
background: #0a0f16; background: var(--field);
border: 1px solid var(--line); border: 1px solid var(--line);
padding: 0.75rem 0.95rem; padding: 0.75rem 0.95rem;
border-radius: 12px; border-radius: 12px;
color: #cbd5f5; color: var(--text-soft);
font-size: 0.78rem; font-size: 0.78rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
margin: 0.5rem 0 0.85rem; margin: 0.5rem 0 0.85rem;
@@ -884,11 +910,11 @@ tr:last-child td {
.raw pre { .raw pre {
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
background: #0a0f16; background: var(--field);
border: 1px solid var(--line); border: 1px solid var(--line);
padding: 0.75rem; padding: 0.75rem;
border-radius: 12px; border-radius: 12px;
color: #cbd5f5; color: var(--text-soft);
font-size: 0.78rem; font-size: 0.78rem;
} }
@@ -927,7 +953,7 @@ tr:last-child td {
padding: 0.55rem 0.65rem; padding: 0.55rem 0.65rem;
border-radius: 10px; border-radius: 10px;
border: 1px solid var(--line); border: 1px solid var(--line);
background: #0a0f16; background: var(--field);
color: var(--text); color: var(--text);
font: inherit; font: inherit;
} }
@@ -975,7 +1001,7 @@ tr:last-child td {
margin-top: auto; margin-top: auto;
padding: 1.25rem clamp(1rem, 3vw, 1.5rem) 1.5rem; padding: 1.25rem clamp(1rem, 3vw, 1.5rem) 1.5rem;
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
background: rgba(10, 15, 22, 0.92); background: var(--overlay);
} }
.support-line { .support-line {
@@ -1227,3 +1253,12 @@ tr:last-child td {
.instance-empty { padding: 12px; } .instance-empty { padding: 12px; }
.inst-src { font-size: 0.6rem; color: var(--muted); border: 1px solid var(--line); border-radius: 999px; padding: 2px 7px; vertical-align: middle; } .inst-src { font-size: 0.6rem; color: var(--muted); border: 1px solid var(--line); border-radius: 999px; padding: 2px 7px; vertical-align: middle; }
/* Светлая тема: пастельные надписи тёмной темы на белом не читаются */
html[data-theme="light"] .clock-zone-diff--accent,
html[data-theme="light"] .cascade-guide a,
html[data-theme="light"] .cascade-guide a:hover { color: #0369a1; }
html[data-theme="light"] .btn.warn { color: #9f1239; }
html[data-theme="light"] .status.err { color: #b91c1c; }
html[data-theme="light"] .badge.on { color: #166534; }
html[data-theme="light"] body.page { background: var(--bg); }

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Deploy / remove an AmneziaWG server instance from public images — no Amnezia app. # Deploy / remove an AmneziaWG server instance from public images — no Amnezia app.
# Variants: awg2 (AmneziaWG 2.0, awg-go 2.0.0), awg (classic, awg-go 0.2.18), # Variants: awg31 (AmneziaWG 3.1), awg3 (3.0), awg2 (2.0, awg-go 2.0.0),
# legacy (kernel WireGuard, amnezia-wg). # awg (1.x for routers/old apps, awg-go 0.2.18), legacy (plain WireGuard, amnezia-wg).
# Usage: # Usage:
# awg-instance.sh create <variant> <port> [name] # awg-instance.sh create <variant> <port> [name]
# awg-instance.sh remove <name> # awg-instance.sh remove <name>
@@ -12,6 +12,9 @@ INSTANCES_DIR="${INSTANCES_DIR:-/opt/amnezia-instances}"
IMG_AWG2="${IMG_AWG2:-amneziavpn/amneziawg-go:2.0.0}" IMG_AWG2="${IMG_AWG2:-amneziavpn/amneziawg-go:2.0.0}"
IMG_AWG="${IMG_AWG:-amneziavpn/amneziawg-go:0.2.18}" IMG_AWG="${IMG_AWG:-amneziavpn/amneziawg-go:0.2.18}"
IMG_LEGACY="${IMG_LEGACY:-amneziavpn/amnezia-wg:latest}" IMG_LEGACY="${IMG_LEGACY:-amneziavpn/amnezia-wg:latest}"
# 3.x pinned by digest: tags on Docker Hub get rewritten (tools inside differ from the tag name)
IMG_AWG3="${IMG_AWG3:-amneziavpn/amneziawg-go:3.0.20260805@sha256:8447c91637c37536dd99b8bbd4420c819ac9f330f047804197291625bfb0ea8a}"
IMG_AWG31="${IMG_AWG31:-amneziavpn/amneziawg-go:3.1.20260828@sha256:cbafc02b8373a83f428272db6d8001b37bc02e6211cbd8c0cb4e2e3759b12b72}"
err() { echo "ERROR: $*" >&2; exit 1; } err() { echo "ERROR: $*" >&2; exit 1; }
@@ -28,10 +31,12 @@ rand_magic() { # single large uint32 (AmneziaWG H header), NOT a range
image_for() { image_for() {
case "$1" in case "$1" in
awg31) echo "$IMG_AWG31" ;;
awg3) echo "$IMG_AWG3" ;;
awg2) echo "$IMG_AWG2" ;; awg2) echo "$IMG_AWG2" ;;
awg) echo "$IMG_AWG" ;; awg) echo "$IMG_AWG" ;;
legacy) echo "$IMG_LEGACY" ;; legacy) echo "$IMG_LEGACY" ;;
*) err "unknown variant: $1 (awg2|awg|legacy)" ;; *) err "unknown variant: $1 (awg31|awg3|awg2|awg|legacy)" ;;
esac esac
} }
@@ -63,7 +68,8 @@ cmd_create() {
[[ -n "$name" ]] || err "bad name" [[ -n "$name" ]] || err "bad name"
docker inspect "$name" >/dev/null 2>&1 && err "container $name already exists" docker inspect "$name" >/dev/null 2>&1 && err "container $name already exists"
# port free? # port free?
if ss -lun 2>/dev/null | grep -qE "[:.]${port}\b"; then err "udp port $port busy"; fi # this script runs inside the panel container: its own `ss` does not see host ports, docker does
if docker ps --format '{{.Ports}}' | tr ',' '\n' | grep -qE ":${port}->[0-9]+/udp"; then err "udp port $port busy (другой контейнер)"; fi
echo "→ pulling $img" echo "→ pulling $img"
docker pull -q "$img" >/dev/null docker pull -q "$img" >/dev/null
@@ -87,22 +93,37 @@ cmd_create() {
{ {
echo "[Interface]" echo "[Interface]"
echo "PrivateKey = $priv" echo "PrivateKey = $priv"
echo "Address = ${net}.0/24" echo "Address = ${net}.1/24"
echo "ListenPort = $port" echo "ListenPort = $port"
if [[ "$variant" != "legacy" ]]; then if [[ "$variant" != "legacy" ]]; then
# 3.x header protection rejects junk sizes <= 12
local smin=15
echo "Jc = $(rand_range 3 10)" echo "Jc = $(rand_range 3 10)"
echo "Jmin = 10" echo "Jmin = 10"
echo "Jmax = 50" echo "Jmax = 50"
echo "S1 = $(rand_range 15 60)" echo "S1 = $(rand_range $smin 60)"
echo "S2 = $(rand_range 15 60)" echo "S2 = $(rand_range $smin 60)"
if [[ "$variant" == "awg2" ]]; then if [[ "$variant" != "awg" ]]; then
echo "S3 = $(rand_range 5 40)" echo "S3 = $(rand_range $smin 40)"
echo "S4 = $(rand_range 1 40)" echo "S4 = $(rand_range $smin 40)"
fi fi
echo "H1 = $(rand_magic)" echo "H1 = $(rand_magic)"
echo "H2 = $(rand_magic)" echo "H2 = $(rand_magic)"
echo "H3 = $(rand_magic)" echo "H3 = $(rand_magic)"
echo "H4 = $(rand_magic)" echo "H4 = $(rand_magic)"
if [[ "$variant" == awg3* ]]; then
echo "HeaderProtectionKey = $(docker run --rm "$img" awg genkey | tr -d '\r\n')"
echo "ContentPaddingAddition = 10-100"
echo "RekeyAfterTime = 100-120"
echo "RekeyTimeout = 3-7"
echo "RejectAfterTime = 150-180"
echo "KeepaliveTimeout = 5-15"
echo "MaxHandshakeAttempts = 15-20"
fi
if [[ "$variant" == "awg31" ]]; then
echo "RandomTrailers = on"
echo "DisableCookies = on"
fi
fi fi
} > "$conf" } > "$conf"
@@ -114,6 +135,13 @@ cmd_create() {
# start script run inside container # start script run inside container
local usimpl="" local usimpl=""
if [[ "$variant" != "legacy" ]]; then usimpl="export WG_QUICK_USERSPACE_IMPLEMENTATION=amneziawg-go"; fi if [[ "$variant" != "legacy" ]]; then usimpl="export WG_QUICK_USERSPACE_IMPLEMENTATION=amneziawg-go"; fi
# 3.x is userspace-only: a loaded amneziawg kernel module (2.0 on the same host) does not know 3.x keys
if [[ "$variant" == awg3* ]]; then
usimpl="$usimpl"$'\n'"$(cat <<'PATCH'
sed -i -e 's/if ! cmd ip link add "$INTERFACE" type amneziawg; then/if true; then/' -e 's|^\([[:space:]]*\)\[\[ -e /sys/module/amneziawg \]\].*|\1:|' /usr/bin/awg-quick
PATCH
)"
fi
cat > "$dir/start.sh" <<EOF cat > "$dir/start.sh" <<EOF
#!/bin/sh #!/bin/sh
set +e set +e
@@ -156,7 +184,7 @@ cmd_remove() {
} }
cmd_list() { cmd_list() {
docker ps --format '{{.Names}} {{.Ports}}' | grep -E '^amnezia-(awg2|awg|legacy)-|^amnezia-' || true docker ps --format '{{.Names}} {{.Ports}}' | grep -E '^amnezia-' || true
} }
case "${1:-}" in case "${1:-}" in

View File

@@ -161,7 +161,8 @@ if [[ -z "${AWG_PROFILES:-}" ]] && [[ -f "${AWG_PROFILE_SNAPSHOT}" ]]; then
fi fi
fi fi
__awg_names="$(docker ps --format '{{.Names}}' 2>/dev/null | awk '/^amnezia-awg/ { print }' | sort || true)" # Контейнеры, развёрнутые самой панелью (/opt/amnezia-instances/<имя>), панель знает сама — в AWG_PROFILES их не кладём.
__awg_names="$(docker ps --format '{{.Names}}' 2>/dev/null | awk '/^amnezia-awg/ { print }' | while read -r __n; do [[ -d "/opt/amnezia-instances/${__n}" ]] || echo "${__n}"; done | sort || true)"
__awg_multi_count="$(printf '%s\n' "${__awg_names}" | sed '/^$/d' | wc -l | tr -d '[:space:]')" __awg_multi_count="$(printf '%s\n' "${__awg_names}" | sed '/^$/d' | wc -l | tr -d '[:space:]')"
__profile_container_count=0 __profile_container_count=0
if [[ -n "${AWG_PROFILES:-}" ]]; then if [[ -n "${AWG_PROFILES:-}" ]]; then

275
server.js
View File

@@ -3,6 +3,7 @@ import { spawn, spawnSync } from "child_process";
import crypto from "crypto"; import crypto from "crypto";
import path from "path"; import path from "path";
import fs from "fs"; import fs from "fs";
import dns from "dns";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import { healClientsMeta, readClientsMeta, writeClientsMeta } from "./scripts/clients-meta.mjs"; import { healClientsMeta, readClientsMeta, writeClientsMeta } from "./scripts/clients-meta.mjs";
@@ -87,6 +88,7 @@ function parseProfilesFromEnv() {
return [ return [
{ {
id: "awg", id: "awg",
isDefault: true,
label: process.env.AWG_PROFILE_LABEL || "AmneziaWG", label: process.env.AWG_PROFILE_LABEL || "AmneziaWG",
container: process.env.AWG_CONTAINER || "amnezia-awg", container: process.env.AWG_CONTAINER || "amnezia-awg",
confPath: process.env.AWG_CONF_PATH || "/opt/amnezia/awg/awg0.conf", confPath: process.env.AWG_CONF_PATH || "/opt/amnezia/awg/awg0.conf",
@@ -137,25 +139,36 @@ function parseProfilesFromEnv() {
} }
const ENV_PROFILES = parseProfilesFromEnv(); const ENV_PROFILES = parseProfilesFromEnv();
let defaultProfileMissing = false; // set by createRuntime when the default profile resolves to nothing
const INSTANCES_DIR = process.env.INSTANCES_DIR || "/opt/amnezia-instances"; const INSTANCES_DIR = process.env.INSTANCES_DIR || "/opt/amnezia-instances";
const INSTANCES_FILE = `${process.env.DATA_DIR || "/data"}/instances.json`; const INSTANCES_FILE = `${process.env.DATA_DIR || "/data"}/instances.json`;
const INSTANCE_SCRIPT = `${process.env.APP_DIR || "/app"}/scripts/awg-instance.sh`; const INSTANCE_SCRIPT = `${process.env.APP_DIR || "/app"}/scripts/awg-instance.sh`;
const INSTANCE_VARIANTS = { const INSTANCE_VARIANTS = {
awg31: {
label: "AmneziaWG 3.1",
desc: "Новейшая версия: защита заголовков, случайные хвосты пакетов. Нужно свежее приложение Amnezia.",
iface: "awg0", binary: "awg",
},
awg3: {
label: "AmneziaWG 3.0",
desc: "Защита заголовков (HeaderProtectionKey), паддинг. Приложение Amnezia 4.8.x и новее.",
iface: "awg0", binary: "awg",
},
awg2: { awg2: {
label: "AmneziaWG 2.0", label: "AmneziaWG 2.0",
desc: "Новая версия протокола на основе awg-go. Расширенная обфускация (S3, S4).", desc: "Новая версия протокола на основе awg-go. Расширенная обфускация (S3, S4).",
iface: "awg0", binary: "awg", iface: "awg0", binary: "awg",
}, },
awg: { awg: {
label: "AmneziaWG", label: "AmneziaWG 1.x (роутеры)",
desc: "Версия протокола на основе awg-go. Обфускация S1, S2.", desc: "Обфускация Jc/S1/S2/H1–H4 без S3/S4 — для Keenetic, OpenWrt и старых приложений.",
iface: "awg0", binary: "awg", iface: "awg0", binary: "awg",
}, },
legacy: { legacy: {
label: "AmneziaWG Legacy", label: "WireGuard (Legacy)",
desc: "Оригинальная версия на ядре WireGuard. Совместима с клиентами старых версий.", desc: "Чистый WireGuard без обфускации — любой WG-клиент и любые роутеры. Легко блокируется DPI.",
iface: "wg0", binary: "wg", iface: "wg0", binary: "wg",
}, },
}; };
@@ -219,10 +232,16 @@ function profileIdentityKey(p) {
// Effective profile set = env profiles + managed instances (deduped by id and target files). // Effective profile set = env profiles + managed instances (deduped by id and target files).
function getProfiles() { function getProfiles() {
const managed = loadManagedProfiles(); const managed = loadManagedProfiles();
const managedContainers = new Set(managed.map((m) => m.container));
const seenIds = new Set(); const seenIds = new Set();
const seenTargets = new Set(); const seenTargets = new Set();
const out = []; const out = [];
for (const p of [...ENV_PROFILES, ...managed]) { // install.sh used to put panel-managed containers into AWG_PROFILES too: the env copy then
// shadowed the managed one (wrong id, no variant). Managed instance wins.
const envProfiles = ENV_PROFILES.filter((p) => !managedContainers.has(p.container));
// Default profile (no AWG_PROFILES) that found no container: hide it once the panel has its own instances.
const env = managed.length && defaultProfileMissing ? envProfiles.filter((p) => !p.isDefault) : envProfiles;
for (const p of [...env, ...managed]) {
const id = String(p.id || "").trim(); const id = String(p.id || "").trim();
const target = profileIdentityKey(p); const target = profileIdentityKey(p);
if ((id && seenIds.has(id)) || (target !== "|||" && seenTargets.has(target))) continue; if ((id && seenIds.has(id)) || (target !== "|||" && seenTargets.has(target))) continue;
@@ -546,6 +565,30 @@ async function dockerRestartContainer(container) {
throw new Error("Контейнер не ответил после restart"); throw new Error("Контейнер не ответил после restart");
} }
// awg-quick brings up both AmneziaWG and plain WireGuard configs; old images only have wg-quick.
const QUICK_SH = "$(command -v awg-quick || echo wg-quick)";
/**
* Upstream for a real cascade: a client .conf of ANOTHER server (AWG or WG) becomes the "warp"
* interface of this instance. Routing of selected clients reuses the WARP machinery.
*/
function buildUpstreamConf(text) {
const sections = parseWireGuardSections(text);
const iface = sections.interface || {};
const peer = sections.peer || {};
if (!iface.PrivateKey || !peer.PublicKey || !peer.Endpoint) {
throw new Error("Нужен клиентский конфиг другого сервера: [Interface] с PrivateKey и [Peer] с PublicKey и Endpoint.");
}
const lines = ["[Interface]", `PrivateKey = ${iface.PrivateKey}`];
if (iface.Address) lines.push(`Address = ${String(iface.Address).split(",")[0].trim()}`);
for (const k of AWG_OBF_KEYS) if (iface[k]) lines.push(`${k} = ${iface[k]}`);
lines.push(`MTU = ${iface.MTU || 1280}`, "Table = off", "", "[Peer]", `PublicKey = ${peer.PublicKey}`);
if (peer.PresharedKey) lines.push(`PresharedKey = ${peer.PresharedKey}`);
lines.push("AllowedIPs = 0.0.0.0/0", `Endpoint = ${peer.Endpoint}`, "PersistentKeepalive = 25", "");
for (const l of lines) if (/['`$\\]/.test(l)) throw new Error("Недопустимые символы в конфиге.");
return lines.join("\n");
}
async function warpFileExists(rt, remotePath) { async function warpFileExists(rt, remotePath) {
try { try {
await execDocker(["exec", await rt.resolveContainer(), "test", "-f", remotePath]); await execDocker(["exec", await rt.resolveContainer(), "test", "-f", remotePath]);
@@ -637,7 +680,7 @@ function buildWarpBootBlock(warpConf, ips) {
return ( return (
"# --- WARP-MANAGER BEGIN ---\n\n" + "# --- WARP-MANAGER BEGIN ---\n\n" +
`if [ -f '${warpConf}' ]; then\n` + `if [ -f '${warpConf}' ]; then\n` +
` wg-quick up '${warpConf}' || true\n` + ` ${QUICK_SH} up '${warpConf}' || true\n` +
` sleep 3\n` + ` sleep 3\n` +
`fi\n\n` + `fi\n\n` +
routing + routing +
@@ -745,7 +788,7 @@ async function warpSummaryForRt(rt) {
let wgShowWarp = ""; let wgShowWarp = "";
if (installed && running) { if (installed && running) {
try { try {
wgShowWarp = await rt.dockerExec("wg show warp 2>/dev/null || true"); wgShowWarp = await rt.dockerExec("awg show warp 2>/dev/null || wg show warp 2>/dev/null || true");
} catch { } catch {
wgShowWarp = ""; wgShowWarp = "";
} }
@@ -800,7 +843,8 @@ async function containerHasFile(name, filePath) {
// Lets the panel work even if AWG_CONTAINER points at the wrong name // Lets the panel work even if AWG_CONTAINER points at the wrong name
// (e.g. configured "amnezia-awg" but Amnezia created "amnezia-awg2"). // (e.g. configured "amnezia-awg" but Amnezia created "amnezia-awg2").
async function discoverAwgContainer(confPath, preferredName) { async function discoverAwgContainer(confPath, preferredName) {
const names = await listRunningContainerNames(); const managed = new Set(loadManagedProfiles().map((m) => m.container));
const names = (await listRunningContainerNames()).filter((n) => n === preferredName || !managed.has(n));
const score = (n) => { const score = (n) => {
if (preferredName && n === preferredName) return 0; if (preferredName && n === preferredName) return 0;
if (/^amnezia-?awg/i.test(n)) return 1; if (/^amnezia-?awg/i.test(n)) return 1;
@@ -852,6 +896,7 @@ function createRuntime(profile) {
if (profile.container) { if (profile.container) {
for (const candidate of confCandidates) { for (const candidate of confCandidates) {
if (await containerHasFile(profile.container, candidate)) { if (await containerHasFile(profile.container, candidate)) {
if (profile.isDefault) defaultProfileMissing = false;
resolvedContainer = profile.container; resolvedContainer = profile.container;
return resolvedContainer; return resolvedContainer;
} }
@@ -862,6 +907,7 @@ function createRuntime(profile) {
found = await discoverAwgContainer(candidate, profile.container); found = await discoverAwgContainer(candidate, profile.container);
if (found) break; if (found) break;
} }
if (profile.isDefault) defaultProfileMissing = !found;
if (found) { if (found) {
resolvedContainer = found; resolvedContainer = found;
if (found !== profile.container) { if (found !== profile.container) {
@@ -996,7 +1042,7 @@ function createRuntime(profile) {
const activeIface = ifaceFromConfPath(activeConfPath, iface); const activeIface = ifaceFromConfPath(activeConfPath, iface);
const activeBinary = await resolveWgBinary(); const activeBinary = await resolveWgBinary();
await dockerExec( await dockerExec(
`wg-quick strip '${activeConfPath}' > /tmp/wg-admin-strip.conf && ${activeBinary} syncconf ${activeIface} /tmp/wg-admin-strip.conf` `(awg-quick strip '${activeConfPath}' 2>/dev/null || wg-quick strip '${activeConfPath}') > /tmp/wg-admin-strip.conf && ${activeBinary} syncconf ${activeIface} /tmp/wg-admin-strip.conf`
); );
} }
@@ -1186,26 +1232,45 @@ function defaultPeerClientName(peer) {
return ip ? `Клиент ${ip}` : `Peer ${String(peer?.publicKey || "").slice(0, 10)}…`; return ip ? `Клиент ${ip}` : `Peer ${String(peer?.publicKey || "").slice(0, 10)}…`;
} }
/** Совпадает с defaults Amnezia Desktop (protocolConstants awg, desktop MTU). */
const AWG_EXPORT_DEFAULTS = { // Client-side obfuscation keys, in the order Amnezia writes them. 3.x keys must match the server.
Jc: "3", const AWG_OBF_KEYS = [
Jmin: "10", "Jc", "Jmin", "Jmax", "S1", "S2", "S3", "S4", "H1", "H2", "H3", "H4", "I1", "I2", "I3", "I4", "I5",
Jmax: "30", "HeaderProtectionKey", "ContentPaddingAddition", "RekeyAfterTime", "RekeyTimeout", "RejectAfterTime",
S1: "15", "KeepaliveTimeout", "MaxHandshakeAttempts", "RandomTrailers", "DisableCookies",
S2: "18", ];
S3: "20", const AWG_OBF_ALIASES = {
S4: "23", Jc: ["junk_packet_count", "junkPacketCount"], Jmin: ["junk_packet_min_size", "junkPacketMinSize"],
H1: "1020325451", Jmax: ["junk_packet_max_size", "junkPacketMaxSize"], S1: ["init_packet_junk_size", "initPacketJunkSize"],
H2: "3288052141", S2: ["response_packet_junk_size", "responsePacketJunkSize"], S3: ["cookie_reply_packet_junk_size", "cookieReplyPacketJunkSize"],
H3: "1766607858", S4: ["transport_packet_junk_size", "transportPacketJunkSize"], H1: ["init_packet_magic_header", "initPacketMagicHeader"],
H4: "2528465083", H2: ["response_packet_magic_header", "responsePacketMagicHeader"], H3: ["underload_packet_magic_header", "underloadPacketMagicHeader"],
I1: "<r 2><b 0x858000010001000000000669636c6f756403636f6d0000010001c00c000100010000105a00044d583737>", H4: ["transport_packet_magic_header", "transportPacketMagicHeader"],
I2: "", I1: ["special_junk_1", "specialJunk1"], I2: ["special_junk_2", "specialJunk2"], I3: ["special_junk_3", "specialJunk3"],
I3: "", I4: ["special_junk_4", "specialJunk4"], I5: ["special_junk_5", "specialJunk5"],
I4: "", HeaderProtectionKey: ["header_protection_key"],
I5: "",
}; };
/**
* Obfuscation lines for a client .conf. The server [Interface] is the source of truth: a key the
* server does not have (e.g. S3/S4 on AWG 1.x) must not appear in the client, otherwise the
* handshake passes but data packets are dropped. Keys only in last_config (I1..I5 written by the
* Amnezia app) are kept. Returns "" for plain WireGuard servers.
*/
function awgObfuscationLines(lc, ifaceMap) {
if (!ifaceMap.Jc && !pickLc(lc, "Jc", "junk_packet_count", "junkPacketCount")) return "";
const lines = [];
for (const k of AWG_OBF_KEYS) {
const isServerKey = !/^I\d$/.test(k);
let v = isServerKey ? ifaceMap[k] : undefined;
if (v == null || String(v).trim() === "") v = pickLc(lc, k, ...(AWG_OBF_ALIASES[k] || []));
if (!isServerKey && (v == null || String(v).trim() === "")) v = ifaceMap[k];
if (isServerKey && ifaceMap.Jc && (ifaceMap[k] == null || ifaceMap[k] === "")) v = undefined;
if (v != null && String(v).trim() !== "") lines.push(`${k} = ${String(v).trim()}`);
}
return lines.length ? `${lines.join("\n")}\n` : "";
}
function parseLastConfigFromClientRow(row) { function parseLastConfigFromClientRow(row) {
const ud = row?.userData; const ud = row?.userData;
if (!ud || typeof ud !== "object") return null; if (!ud || typeof ud !== "object") return null;
@@ -1490,8 +1555,28 @@ function tunnelClientIpv4(peer, lc, row) {
); );
} }
// Panel settings editable from the UI (domain for client Endpoint etc.).
const SETTINGS_FILE = path.join(process.env.DATA_DIR || "/data", "settings.json");
function loadPanelSettings() {
try {
const o = JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8"));
return o && typeof o === "object" ? o : {};
} catch {
return {};
}
}
function savePanelSettings(next) {
fs.mkdirSync(path.dirname(SETTINGS_FILE), { recursive: true });
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(next, null, 2));
}
/** Domain/IP set in the UI wins over CLIENT_CONFIG_ENDPOINT (installer puts the raw IP there). */
function configuredEndpointHost() {
const ui = String(loadPanelSettings().endpointHost || "").trim();
return ui || process.env.CLIENT_CONFIG_ENDPOINT?.trim() || "";
}
function resolveExportEndpointHost(lc, req) { function resolveExportEndpointHost(lc, req) {
const env = process.env.CLIENT_CONFIG_ENDPOINT?.trim(); const env = configuredEndpointHost();
if (env) return env; if (env) return env;
const hn = pickLc(lc, "hostName", "hostname", "host"); const hn = pickLc(lc, "hostName", "hostname", "host");
if (hn && String(hn).trim()) return String(hn).trim(); if (hn && String(hn).trim()) return String(hn).trim();
@@ -1546,70 +1631,11 @@ async function buildClientConfExport(rt, lc, ifaceMap, req, row, conf) {
const mtuVal = pickLc(lc, "mtu", "MTU"); const mtuVal = pickLc(lc, "mtu", "MTU");
const mtuLine = mtuVal ? `MTU = ${String(mtuVal).trim()}\n` : ""; const mtuLine = mtuVal ? `MTU = ${String(mtuVal).trim()}\n` : "";
if (activeBinary === "awg") {
const Jc = String(pickLc(lc, "Jc", "junk_packet_count", "junkPacketCount") ?? AWG_EXPORT_DEFAULTS.Jc);
const Jmin = String(pickLc(lc, "Jmin", "junk_packet_min_size", "junkPacketMinSize") ?? AWG_EXPORT_DEFAULTS.Jmin);
const Jmax = String(pickLc(lc, "Jmax", "junk_packet_max_size", "junkPacketMaxSize") ?? AWG_EXPORT_DEFAULTS.Jmax);
const S1 = String(pickLc(lc, "S1", "init_packet_junk_size", "initPacketJunkSize") ?? AWG_EXPORT_DEFAULTS.S1);
const S2 = String(pickLc(lc, "S2", "response_packet_junk_size", "responsePacketJunkSize") ?? AWG_EXPORT_DEFAULTS.S2);
const S3 = String(
pickLc(lc, "S3", "cookie_reply_packet_junk_size", "cookieReplyPacketJunkSize") ?? AWG_EXPORT_DEFAULTS.S3,
);
const S4 = String(
pickLc(lc, "S4", "transport_packet_junk_size", "transportPacketJunkSize") ?? AWG_EXPORT_DEFAULTS.S4,
);
const H1 = String(pickLc(lc, "H1", "init_packet_magic_header", "initPacketMagicHeader") ?? AWG_EXPORT_DEFAULTS.H1);
const H2 = String(
pickLc(lc, "H2", "response_packet_magic_header", "responsePacketMagicHeader") ?? AWG_EXPORT_DEFAULTS.H2,
);
const H3 = String(
pickLc(lc, "H3", "underload_packet_magic_header", "underloadPacketMagicHeader") ?? AWG_EXPORT_DEFAULTS.H3,
);
const H4 = String(
pickLc(lc, "H4", "transport_packet_magic_header", "transportPacketMagicHeader") ?? AWG_EXPORT_DEFAULTS.H4,
);
const I1 = String(pickLc(lc, "I1", "special_junk_1", "specialJunk1") ?? AWG_EXPORT_DEFAULTS.I1);
const I2 = String(pickLc(lc, "I2", "special_junk_2", "specialJunk2") ?? AWG_EXPORT_DEFAULTS.I2);
const I3 = String(pickLc(lc, "I3", "special_junk_3", "specialJunk3") ?? AWG_EXPORT_DEFAULTS.I3);
const I4 = String(pickLc(lc, "I4", "special_junk_4", "specialJunk4") ?? AWG_EXPORT_DEFAULTS.I4);
const I5 = String(pickLc(lc, "I5", "special_junk_5", "specialJunk5") ?? AWG_EXPORT_DEFAULTS.I5);
const iLines = [
["I1", I1], ["I2", I2], ["I3", I3], ["I4", I4], ["I5", I5],
]
.filter(([, v]) => String(v ?? "").trim() !== "")
.map(([k, v]) => `${k} = ${v}`)
.join("\n");
const iBlock = iLines ? `${iLines}\n` : "";
return `[Interface]
Address = ${tunnelIp}/32
DNS = ${dns1}, ${dns2}
PrivateKey = ${priv.trim()}
Jc = ${Jc}
Jmin = ${Jmin}
Jmax = ${Jmax}
S1 = ${S1}
S2 = ${S2}
S3 = ${S3}
S4 = ${S4}
H1 = ${H1}
H2 = ${H2}
H3 = ${H3}
H4 = ${H4}
${iBlock}${mtuLine}[Peer]
PublicKey = ${String(serverPub).trim()}
PresharedKey = ${String(psk).trim()}
AllowedIPs = ${peerAllowed}
Endpoint = ${endpointHost}:${port}
PersistentKeepalive = ${keepAlive}
`;
}
return `[Interface] return `[Interface]
Address = ${tunnelIp}/32 Address = ${tunnelIp}/32
DNS = ${dns1}, ${dns2} DNS = ${dns1}, ${dns2}
PrivateKey = ${priv.trim()} PrivateKey = ${priv.trim()}
${mtuLine}[Peer] ${awgObfuscationLines(lc, ifaceMap)}${mtuLine}[Peer]
PublicKey = ${String(serverPub).trim()} PublicKey = ${String(serverPub).trim()}
PresharedKey = ${String(psk).trim()} PresharedKey = ${String(psk).trim()}
AllowedIPs = ${peerAllowed} AllowedIPs = ${peerAllowed}
@@ -1652,7 +1678,7 @@ async function awgGenKeypair(rt) {
} }
function obfuscationFieldsFromServerHead(ifaceMap) { function obfuscationFieldsFromServerHead(ifaceMap) {
const keys = ["Jc", "Jmin", "Jmax", "S1", "S2", "S3", "S4", "H1", "H2", "H3", "H4", "I1", "I2", "I3", "I4", "I5"]; const keys = AWG_OBF_KEYS;
const out = {}; const out = {};
for (const k of keys) { for (const k of keys) {
const v = ifaceMap[k]; const v = ifaceMap[k];
@@ -2427,6 +2453,40 @@ app.post("/api/change-password", requireAuth, (req, res) => {
res.json({ ok: true, message: "Пароль изменён. Войдите снова." }); res.json({ ok: true, message: "Пароль изменён. Войдите снова." });
}); });
app.get("/api/settings", requireAuth, (_req, res) => {
const st = loadPanelSettings();
res.json({
endpointHost: st.endpointHost || "",
envEndpoint: process.env.CLIENT_CONFIG_ENDPOINT?.trim() || "",
effectiveEndpoint: configuredEndpointHost(),
});
});
app.post("/api/settings", requireAuth, requireProTier, async (req, res) => {
const raw = String(req.body?.endpointHost ?? "").trim();
let host = "";
if (raw) {
try {
host = assertCascadeEndpointHost(raw);
} catch (e) {
return res.status(400).json({ error: String(e.message || e) });
}
if (!/^[A-Za-z0-9.-]+$/.test(host) && !/^[0-9a-fA-F:]+$/.test(host)) {
return res.status(400).json({ error: "Домен или IP: только латиница, цифры, точки и дефисы." });
}
}
let resolved = null;
if (host && !/^[\d.]+$/.test(host) && host.includes(".")) {
try {
resolved = (await dns.promises.lookup(host, { all: true })).map((a) => a.address);
} catch {
return res.status(400).json({ error: `Домен ${host} не резолвится. Создайте A-запись на IP этого сервера и повторите.` });
}
}
savePanelSettings({ ...loadPanelSettings(), endpointHost: host });
res.json({ ok: true, endpointHost: host, resolved });
});
app.get("/api/protocols", requireAuth, (req, res) => { app.get("/api/protocols", requireAuth, (req, res) => {
const rt = runtimeForRequest(req); const rt = runtimeForRequest(req);
const hintSingle = const hintSingle =
@@ -2757,7 +2817,7 @@ app.post("/api/clients/create", requireAuth, requireProTier, async (req, res) =>
} }
const tunnelIp = normalizeCascadeTunnelIp(conf, ifaceMap, req.body?.tunnelIp); const tunnelIp = normalizeCascadeTunnelIp(conf, ifaceMap, req.body?.tunnelIp);
const listenPort = ifaceMap.ListenPort ? Number(ifaceMap.ListenPort) : NaN; const listenPort = ifaceMap.ListenPort ? Number(ifaceMap.ListenPort) : NaN;
const envHost = process.env.CLIENT_CONFIG_ENDPOINT?.trim(); const envHost = configuredEndpointHost();
const hdrHost = const hdrHost =
typeof req.headers.host === "string" ? req.headers.host.split(":")[0].trim() : ""; typeof req.headers.host === "string" ? req.headers.host.split(":")[0].trim() : "";
const endpointHost = envHost || (hdrHost && hdrHost !== "localhost" ? hdrHost : ""); const endpointHost = envHost || (hdrHost && hdrHost !== "localhost" ? hdrHost : "");
@@ -2992,8 +3052,8 @@ app.post("/api/warp/start", requireAuth, requireProTier, async (req, res) => {
}); });
} }
try { try {
await rt.dockerExec(`wg-quick down '${rt.profile.warpConf}' 2>/dev/null || true`); await rt.dockerExec(`${QUICK_SH} down '${rt.profile.warpConf}' 2>/dev/null || true`);
await rt.dockerExec(`wg-quick up '${rt.profile.warpConf}'`); await rt.dockerExec(`${QUICK_SH} up '${rt.profile.warpConf}'`);
res.json({ ok: true }); res.json({ ok: true });
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@@ -3001,6 +3061,31 @@ app.post("/api/warp/start", requireAuth, requireProTier, async (req, res) => {
} }
}); });
app.post("/api/cascade/upstream", requireAuth, requireProTier, async (req, res) => {
if (UI_HIDDEN.warp || UI_HIDDEN.cascade) {
return res.status(403).json({ error: MSG_UI_CASCADE_OFF });
}
const rt = runtimeForRequest(req);
try {
const conf = buildUpstreamConf(String(req.body?.configText || ""));
await rt.backupRemoteFiles();
await rt.dockerExec(`mkdir -p '${rt.profile.warpDir}'`);
await rt.dockerExec(`${QUICK_SH} down '${rt.profile.warpConf}' 2>/dev/null || true`);
await rt.dockerWriteFile(rt.profile.warpConf, conf);
await rt.dockerExec(`chmod 600 '${rt.profile.warpConf}'`);
const up = await rt.dockerExec(`${QUICK_SH} up '${rt.profile.warpConf}' 2>&1`);
const selected = await warpLoadSelectedIps(rt);
await warpApplyRouting(rt, selected);
await warpPatchStartSh(rt, selected);
await new Promise((r) => setTimeout(r, 3000));
const show = await rt.dockerExec("awg show warp 2>/dev/null || wg show warp 2>/dev/null || true");
res.json({ ok: true, handshake: /latest handshake/.test(show), show: show.slice(0, 2000), output: up.slice(-1500) });
} catch (e) {
console.error(e);
res.status(400).json({ error: String(e.message || e).slice(0, 1500) });
}
});
app.post("/api/warp/stop", requireAuth, requireProTier, 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 });
@@ -3010,7 +3095,7 @@ app.post("/api/warp/stop", requireAuth, requireProTier, async (req, res) => {
return res.status(400).json({ error: "WARP не установлен." }); return res.status(400).json({ error: "WARP не установлен." });
} }
try { try {
await rt.dockerExec(`wg-quick down '${rt.profile.warpConf}' 2>/dev/null || true`); await rt.dockerExec(`${QUICK_SH} down '${rt.profile.warpConf}' 2>/dev/null || true`);
res.json({ ok: true }); res.json({ ok: true });
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@@ -3261,6 +3346,8 @@ function runInstanceScript(args) {
} }
function detectVariantFromHead(head, iface) { function detectVariantFromHead(head, iface) {
if (/^RandomTrailers\s*=/m.test(head)) return "awg31";
if (/^HeaderProtectionKey\s*=/m.test(head)) return "awg3";
if (iface === "wg0" || !/^S1\s*=/m.test(head)) return "legacy"; if (iface === "wg0" || !/^S1\s*=/m.test(head)) return "legacy";
if (/^S3\s*=/m.test(head) || /^S4\s*=/m.test(head)) return "awg2"; if (/^S3\s*=/m.test(head) || /^S4\s*=/m.test(head)) return "awg2";
return "awg"; return "awg";
@@ -3269,6 +3356,8 @@ function detectVariantFromHead(head, iface) {
function inferVariantFromProfile(prof) { function inferVariantFromProfile(prof) {
const raw = `${prof.variant || ""} ${prof.id || ""} ${prof.container || ""} ${prof.label || ""}`.toLowerCase(); const raw = `${prof.variant || ""} ${prof.id || ""} ${prof.container || ""} ${prof.label || ""}`.toLowerCase();
if (raw.includes("legacy") || prof.iface === "wg0") return "legacy"; if (raw.includes("legacy") || prof.iface === "wg0") return "legacy";
if (raw.includes("awg31") || raw.includes("3.1")) return "awg31";
if (raw.includes("awg3") || raw.includes("3.0")) return "awg3";
if (raw.includes("awg2") || raw.includes("2.0")) return "awg2"; if (raw.includes("awg2") || raw.includes("2.0")) return "awg2";
return "awg"; return "awg";
} }