feat: cascade client — custom Endpoint + AWG_PROFILES hint

- POST /api/clients/create-cascade: genkeys, add peer, save last_config, download .conf
- Optional tunnel IP in VPN subnet; obfuscation from server [Interface] when present
- /api/protocols: singleProfile hint for missing instance selector
- UI: cascade form, profile banner; README cascade section

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Андрей Бобырев
2026-05-14 18:44:11 +03:00
parent 227bb94627
commit f3aab6cc6e
5 changed files with 384 additions and 4 deletions

View File

@@ -105,6 +105,10 @@ chmod +x scripts/warp-amnezia.sh
Футер с ссылками (**Amnezia Admin WebUI**, Boosty, Ozon СБП, Telegram) в админке находится **внизу страницы** — прокрутите ниже таблицы. Футер с ссылками (**Amnezia Admin WebUI**, Boosty, Ozon СБП, Telegram) в админке находится **внизу страницы** — прокрутите ниже таблицы.
### Каскад (свой Endpoint для клиента)
В панели блок **«Новый клиент под каскад»**: задаёте IP/DNS и при необходимости порт — сервер **генерирует ключи**, добавляет peer в текущий инстанс AmneziaWG и отдаёт `.conf`, где **Endpoint** указывает на ваш промежуточный узел. На этом узле нужен **проброс UDP** на VPS (тот же порт, что слушает WG на сервере, либо свой порт из формы).
### Экспорт конфигурации клиента (.conf) ### Экспорт конфигурации клиента (.conf)
Если в записи клиента на сервере есть **`userData.last_config`** (JSON из приложения Amnezia с полем **`config`** — готовый текст — или с **`client_priv_key`** и ключами сервера), в таблице появятся **«Скачать .conf»**, **«Прямая ссылка»** и **«Копировать URL»**. Прямая ссылка имеет вид Если в записи клиента на сервере есть **`userData.last_config`** (JSON из приложения Amnezia с полем **`config`** — готовый текст — или с **`client_priv_key`** и ключами сервера), в таблице появятся **«Скачать .conf»**, **«Прямая ссылка»** и **«Копировать URL»**. Прямая ссылка имеет вид
@@ -113,7 +117,7 @@ chmod +x scripts/warp-amnezia.sh
Чтобы скачивать **без входа в панель**, задайте **`EXPORT_CONFIG_SECRET`** при установке и открывайте Чтобы скачивать **без входа в панель**, задайте **`EXPORT_CONFIG_SECRET`** при установке и открывайте
`/api/clients/export-config?token=ВАШ_СЕКРЕТ&clientId=…` — при **нескольких** профилях добавьте **`&profileId=…`**. Не пересылайте такую ссылку третьим лицам. `/api/clients/export-config?token=ВАШ_СЕКРЕТ&clientId=…` — при **нескольких** профилях добавьте **`&profileId=…`**. Не пересылайте такую ссылку третьим лицам.
Если на VPS только «голый» `clientsTable` без `last_config`, экспорта не будет — конфиг нужно брать из приложения Amnezia на устройстве. Если на VPS только «голый» `clientsTable` без `last_config`, готового экспорта по старым строкам не будет — используйте блок **«Новый клиент под каскад»** или приложение Amnezia на устройстве.
Для корректного **`Endpoint`** задайте **`CLIENT_CONFIG_ENDPOINT`** при установке (публичный IP или домен VPS), либо убедитесь, что в `last_config` указан **`hostName`**, либо открывайте панель по тому же хосту, который клиенты должны использовать для подключения (не `localhost`). Для корректного **`Endpoint`** задайте **`CLIENT_CONFIG_ENDPOINT`** при установке (публичный IP или домен VPS), либо убедитесь, что в `last_config` указан **`hostName`**, либо открывайте панель по тому же хосту, который клиенты должны использовать для подключения (не `localhost`).

View File

@@ -31,6 +31,8 @@ const pwNew = document.querySelector("#pw-new");
const pwNew2 = document.querySelector("#pw-new2"); const pwNew2 = document.querySelector("#pw-new2");
const pwMsg = document.querySelector("#pw-msg"); const pwMsg = document.querySelector("#pw-msg");
const profileHintEl = document.querySelector("#profile-hint");
const dtDialog = document.querySelector("#disconnect-dt-dialog"); const dtDialog = document.querySelector("#disconnect-dt-dialog");
const dtTitle = document.querySelector("#dt-dialog-title"); const dtTitle = document.querySelector("#dt-dialog-title");
const dtClientEl = document.querySelector("#dt-dialog-client"); const dtClientEl = document.querySelector("#dt-dialog-client");
@@ -202,6 +204,15 @@ async function loadProtocols() {
try { try {
const data = await api("/api/protocols"); const data = await api("/api/protocols");
protoLabel.textContent = `Протокол: ${data.currentLabel || "AmneziaWG"}`; protoLabel.textContent = `Протокол: ${data.currentLabel || "AmneziaWG"}`;
if (profileHintEl) {
if (data.singleProfile && typeof data.profilesPersistHint === "string" && data.profilesPersistHint) {
profileHintEl.textContent = data.profilesPersistHint;
profileHintEl.classList.remove("hidden");
} else {
profileHintEl.textContent = "";
profileHintEl.classList.add("hidden");
}
}
if (!data.profiles || data.profiles.length < 2) { if (!data.profiles || data.profiles.length < 2) {
protoSwitch.classList.add("hidden"); protoSwitch.classList.add("hidden");
return; return;
@@ -252,6 +263,11 @@ refreshBtn.addEventListener("click", () => {
loadClients(); loadClients();
}); });
const cascadeForm = document.querySelector("#cascade-form");
if (cascadeForm) {
cascadeForm.addEventListener("submit", (ev) => void downloadCascadeConf(ev));
}
protoSelect.addEventListener("change", async () => { protoSelect.addEventListener("change", async () => {
try { try {
setStatus("Смена инстанса…", false); setStatus("Смена инстанса…", false);
@@ -516,7 +532,7 @@ function renderRows(clients) {
const exHint = document.createElement("p"); const exHint = document.createElement("p");
exHint.className = "muted export-missing-hint"; exHint.className = "muted export-missing-hint";
exHint.textContent = exHint.textContent =
"Экспорт .conf с сервера недоступен: нет userData.last_config (ключи только в приложении Amnezia)."; "Конфиг с сервера недоступен (нет last_config). Создайте клиента с нужным Endpoint в блоке «Новый клиент под каскад» ниже или возьмите ключ из приложения Amnezia.";
nameWrap.appendChild(exHint); nameWrap.appendChild(exHint);
} }
nameTd.appendChild(nameWrap); nameTd.appendChild(nameWrap);
@@ -728,10 +744,14 @@ function escapeHtml(s) {
.replace(/"/g, "&quot;"); .replace(/"/g, "&quot;");
} }
function currentProfileIdValue() {
if (!protoSelect || !protoSwitch || protoSwitch.classList.contains("hidden")) return "";
return String(protoSelect.value || "").trim();
}
/** Query для нужного инстанса при нескольких профилях AWG_PROFILES */ /** Query для нужного инстанса при нескольких профилях AWG_PROFILES */
function currentProfileQuerySuffix() { function currentProfileQuerySuffix() {
if (!protoSelect || !protoSwitch || protoSwitch.classList.contains("hidden")) return ""; const pid = currentProfileIdValue();
const pid = String(protoSelect.value || "").trim();
return pid ? `&profileId=${encodeURIComponent(pid)}` : ""; return pid ? `&profileId=${encodeURIComponent(pid)}` : "";
} }
@@ -799,6 +819,72 @@ async function downloadClientConfig(c) {
} }
} }
async function downloadCascadeConf(ev) {
ev.preventDefault();
const endpointEl = document.querySelector("#cascade-endpoint");
const portEl = document.querySelector("#cascade-port");
const tunnelEl = document.querySelector("#cascade-tunnel-ip");
const nameEl = document.querySelector("#cascade-name");
const endpointHost = endpointEl?.value.trim() || "";
if (!endpointHost) {
setStatus("Укажите Endpoint (IP или DNS для клиента в каскаде).", true);
return;
}
const body = { endpointHost };
const praw = portEl?.value.trim() ?? "";
if (praw) {
const n = Number(praw);
if (!Number.isFinite(n) || n < 1 || n > 65535) {
setStatus("Некорректный порт Endpoint (165535).", true);
return;
}
body.endpointPort = n;
}
const tip = tunnelEl?.value.trim();
if (tip) body.tunnelIp = tip;
const nm = nameEl?.value.trim();
if (nm) body.clientName = nm;
const pid = currentProfileIdValue();
if (pid) body.profileId = pid;
try {
setStatus("Создаю клиента на сервере и собираю .conf…", false);
const res = await fetch("/api/clients/create-cascade", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const text = await res.text();
if (!res.ok) {
let msg = text;
try {
const j = JSON.parse(text);
msg = typeof j.error === "string" ? j.error : msg;
} catch {
/* raw */
}
throw new Error(msg);
}
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
const safe = (nm || "cascade")
.replace(/[^\w\u0400-\u04FF\-]+/g, "_")
.slice(0, 60);
a.href = url;
a.download = `amnezia-cascade-${safe}.conf`;
a.rel = "noopener";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
setStatus("Клиент добавлен на сервер, .conf скачан. Обновите таблицу.", false);
await loadClients();
} catch (e) {
setStatus(String(e.message || e), true);
}
}
async function renameClient(c) { async function renameClient(c) {
const next = prompt(`Новое имя для «${c.name}»:`, c.name); const next = prompt(`Новое имя для «${c.name}»:`, c.name);
if (next === null) return; if (next === null) return;

View File

@@ -86,6 +86,8 @@
<button type="button" id="refresh" class="btn primary">Обновить</button> <button type="button" id="refresh" class="btn primary">Обновить</button>
</section> </section>
<p id="profile-hint" class="profile-hint muted hidden" role="note"></p>
<p id="status" class="status" role="status"></p> <p id="status" class="status" role="status"></p>
<section class="panel warp-panel" id="warp-panel" hidden> <section class="panel warp-panel" id="warp-panel" hidden>
@@ -106,6 +108,32 @@
</details> </details>
</section> </section>
<section class="panel cascade-panel" id="cascade-panel">
<div class="panel-head">
<h2>Новый клиент под каскад</h2>
</div>
<p class="muted cascade-intro">
Укажите <strong>Endpoint</strong> — публичный IP или DNS узла, <em>куда клиент будет подключаться первым шагом</em>
(промежуточный сервер, домашний роутер с пробросом порта и т.д.). Порт по умолчанию совпадает с <code class="inline">ListenPort</code> этого инстанса.
Клиент будет <strong>создан на текущем сервере AmneziaWG</strong> (новые ключи); IP в туннеле можно задать явно или оставить пустым — подберём свободный в той же подсети, что у остальных.
</p>
<form id="cascade-form" class="cascade-form">
<label for="cascade-endpoint">Endpoint (хост для клиента)</label>
<input id="cascade-endpoint" type="text" placeholder="Например 203.0.113.50 или ddns.example.com" required autocomplete="off">
<label for="cascade-port">Порт Endpoint</label>
<input id="cascade-port" type="number" min="1" max="65535" placeholder="Пусто — как у сервера" autocomplete="off">
<label for="cascade-tunnel-ip">IP в VPN-туннеле (необязательно)</label>
<input id="cascade-tunnel-ip" type="text" placeholder="Например 10.8.1.20 — та же подсеть /24 что у других" autocomplete="off">
<label for="cascade-name">Имя в таблице</label>
<input id="cascade-name" type="text" placeholder="Например Телефон через узел X" autocomplete="off">
<button type="submit" class="btn primary">Создать на сервере и скачать .conf</button>
</form>
</section>
<section class="panel"> <section class="panel">
<div class="panel-head"> <div class="panel-head">
<h2>Пользователи</h2> <h2>Пользователи</h2>

View File

@@ -528,6 +528,35 @@ tr:last-child td {
max-width: 22rem; max-width: 22rem;
} }
.profile-hint {
margin: 0 0 0.75rem;
padding: 0.65rem 0.95rem;
border-radius: 12px;
border: 1px solid rgba(251, 191, 36, 0.35);
background: rgba(251, 191, 36, 0.07);
max-width: 52rem;
}
.cascade-intro code.inline {
font-size: 0.85em;
}
.cascade-form {
display: grid;
gap: 0.55rem;
max-width: 28rem;
margin-top: 0.85rem;
}
.cascade-form label {
font-size: 0.82rem;
color: var(--muted);
}
.cascade-form input {
width: 100%;
}
.raw { .raw {
margin-top: 1.25rem; margin-top: 1.25rem;
color: var(--muted); color: var(--muted);

233
server.js
View File

@@ -968,6 +968,117 @@ PersistentKeepalive = ${keepAlive}
`; `;
} }
function assertCascadeEndpointHost(raw) {
const s = String(raw ?? "").trim();
if (!s || s.length > 253) {
throw new Error("Укажите IP или DNS для Endpoint (куда клиент будет стучаться в каскаде).");
}
if (/[\s<>\"']/.test(s)) {
throw new Error("Недопустимые символы в Endpoint.");
}
return s;
}
function parseIpv4ToParts(ip) {
const m = String(ip).trim().match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (!m) return null;
const o = [1, 2, 3, 4].map((i) => parseInt(m[i], 10));
if (o.some((x) => x > 255 || Number.isNaN(x))) return null;
return o;
}
async function awgGenKeypair(rt) {
const privOut = await rt.dockerExec(`${rt.profile.wgBinary} genkey`);
const priv = privOut.trim().split(/\s+/)[0];
if (!priv || !/^[A-Za-z0-9+/=_-]+$/.test(priv)) {
throw new Error("Не удалось сгенерировать ключ клиента (genkey).");
}
const q = priv.replace(/'/g, `'\\''`);
const pubOut = await rt.dockerExec(`printf '%s\\n' '${q}' | ${rt.profile.wgBinary} pubkey`);
const pub = pubOut.trim().split(/\s+/)[0];
if (!pub) throw new Error("Не удалось получить публичный ключ клиента.");
return { priv, pub };
}
function obfuscationFieldsFromServerHead(ifaceMap) {
const keys = ["Jc", "Jmin", "Jmax", "S1", "S2", "S3", "S4", "H1", "H2", "H3", "H4", "I1", "I2", "I3", "I4", "I5"];
const out = {};
for (const k of keys) {
const v = ifaceMap[k];
if (v != null && String(v).trim() !== "") {
out[k] = String(v).trim();
}
}
return out;
}
function collectUsedTunnelIps(conf) {
const used = new Set();
for (const p of conf.peers) {
const raw = p.allowedIPs || "";
const re = /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?:\/\d+)?/g;
let m;
while ((m = re.exec(raw)) !== null) {
used.add(m[1]);
}
}
return used;
}
function inferSubnetPrefixFromConf(conf, ifaceMap) {
const addrRaw = ifaceMap.Address || ifaceMap.address;
if (addrRaw) {
const chunk = String(addrRaw).split(",")[0].trim();
const parts = parseIpv4ToParts(chunk.split("/")[0]);
if (parts) {
return `${parts[0]}.${parts[1]}.${parts[2]}`;
}
}
for (const p of conf.peers) {
const m = String(p.allowedIPs || "").match(/(\d{1,3}\.\d{1,3}\.\d{1,3})\.\d{1,3}/);
if (m) return m[1];
}
return "10.8.1";
}
function suggestNextTunnelIp(conf, ifaceMap) {
const prefix = inferSubnetPrefixFromConf(conf, ifaceMap);
const used = collectUsedTunnelIps(conf);
let maxLast = 1;
for (const ip of used) {
if (!ip.startsWith(`${prefix}.`)) continue;
const last = parseInt(ip.slice(prefix.length + 1), 10);
if (!Number.isNaN(last)) maxLast = Math.max(maxLast, last);
}
for (let last = Math.max(2, maxLast + 1); last <= 254; last++) {
const candidate = `${prefix}.${last}`;
if (!used.has(candidate)) return candidate;
}
throw new Error("Не нашёл свободный IPv4 в подсети VPN для нового клиента.");
}
function normalizeCascadeTunnelIp(conf, ifaceMap, requested) {
const prefix = inferSubnetPrefixFromConf(conf, ifaceMap);
if (!requested || !String(requested).trim()) {
return suggestNextTunnelIp(conf, ifaceMap);
}
const stripped = String(requested).trim().replace(/\/32$/i, "");
const parts = parseIpv4ToParts(stripped);
if (!parts) {
throw new Error("Некорректный IP туннеля (ожидается IPv4, например 10.8.1.10).");
}
const triple = `${parts[0]}.${parts[1]}.${parts[2]}`;
if (triple !== prefix) {
throw new Error(`IP клиента должен быть в подсети ${prefix}.x как у остальных клиентов этого инстанса.`);
}
const full = `${parts[0]}.${parts[1]}.${parts[2]}.${parts[3]}`;
const used = collectUsedTunnelIps(conf);
if (used.has(full)) {
throw new Error(`Адрес ${full} уже занят другим клиентом.`);
}
return full;
}
async function disableClient(rt, clientId, ts) { async function disableClient(rt, clientId, ts) {
await rt.backupRemoteFiles(); await rt.backupRemoteFiles();
const { conf, clients } = await rt.loadState(); const { conf, clients } = await rt.loadState();
@@ -1298,6 +1409,11 @@ app.get("/api/protocols", requireAuth, (req, res) => {
label: p.label, label: p.label,
container: p.container, container: p.container,
})), })),
singleProfile: PROFILES.length < 2,
profilesPersistHint:
PROFILES.length < 2
? "Сейчас один инстанс: при установке не передали AWG_PROFILES или не восстановился снимок. Задайте JSON профилей и запустите install.sh — он сохранится в /root/amnezia-admin.awg-profiles.json."
: "",
}); });
}); });
@@ -1454,6 +1570,123 @@ app.post("/api/clients/export-config", requireAuth, (req, res) => {
void serveClientConfigExport(req, res); void serveClientConfigExport(req, res);
}); });
/**
* Новый клиент для каскада: генерирует ключи, добавляет peer на сервер, сохраняет last_config,
* отдаёт .conf с Endpoint = endpointHost:endpointPort (ваш промежуточный узел).
*/
app.post("/api/clients/create-cascade", requireAuth, async (req, res) => {
const rt = runtimeFromExportRequest(req);
let endpointHost;
try {
endpointHost = assertCascadeEndpointHost(req.body?.endpointHost);
} catch (e) {
res.status(400).json({ error: String(e.message || e) });
return;
}
let endpointPort;
const rawPort = req.body?.endpointPort;
if (rawPort != null && rawPort !== "") {
endpointPort = Number(rawPort);
if (!Number.isFinite(endpointPort) || endpointPort < 1 || endpointPort > 65535) {
res.status(400).json({ error: "Некорректный порт Endpoint (165535)." });
return;
}
}
try {
await rt.backupRemoteFiles();
const { conf, clients } = await rt.loadState();
const ifaceMap = parseInterfaceKeyValues(conf.head);
if (!ifaceMap.PrivateKey) {
res.status(400).json({ error: "В wg/awg конфиге сервера нет PrivateKey в [Interface]." });
return;
}
const tunnelIp = normalizeCascadeTunnelIp(conf, ifaceMap, req.body?.tunnelIp);
const listenPort = ifaceMap.ListenPort ? Number(ifaceMap.ListenPort) : NaN;
if (endpointPort == null) {
endpointPort =
Number.isFinite(listenPort) && listenPort > 0
? listenPort
: rt.profile.wgBinary === "awg"
? 55424
: 51820;
}
const psk = await rt.inferPskFromConf(conf);
if (!psk || typeof psk !== "string") {
res.status(400).json({ error: "Не удалось определить PresharedKey (нет peer или файла psk)." });
return;
}
const serverPub = await wgPubkeyFromPrivate(rt, ifaceMap.PrivateKey);
const { priv, pub } = await awgGenKeypair(rt);
if (clients.some((c) => c.clientId === pub)) {
res.status(409).json({ error: "Коллизия ключей — попробуйте ещё раз." });
return;
}
const obf = obfuscationFieldsFromServerHead(ifaceMap);
const lc = {
client_priv_key: priv,
server_pub_key: serverPub,
psk_key: psk,
client_ip: tunnelIp,
hostName: endpointHost,
port: endpointPort,
allowed_ips: ["0.0.0.0/0", "::/0"],
...obf,
};
const peerRaw = `[Peer]
PublicKey = ${pub}
PresharedKey = ${psk}
AllowedIPs = ${tunnelIp}/32
`;
const peer = parsePeerBlock(`${peerRaw}\n`);
const nextPeers = [...conf.peers, peer];
const nextConfText = serializeAwgConf(conf.head, nextPeers);
const rawName = req.body?.clientName;
const clientName =
typeof rawName === "string" && rawName.trim()
? rawName.trim().replace(/\s+/g, " ").slice(0, 200)
: `Каскад ${tunnelIp}`;
const last_config = JSON.stringify(lc);
const newRow = {
clientId: pub,
userData: {
clientName,
creationDate: new Date().toISOString(),
last_config,
allowedIps: `${tunnelIp}/32`,
},
};
const nextClients = [...clients, newRow];
await rt.dockerWriteFile(rt.confPath, nextConfText);
await rt.dockerWriteFile(rt.clientsPath, stringifyClientsTable(nextClients));
await rt.applySyncconf();
const confAfter = { ...conf, peers: nextPeers };
let text;
try {
text = await buildClientConfExport(rt, lc, ifaceMap, req, newRow, confAfter);
} catch (e) {
res.status(500).json({ error: String(e.message || e) });
return;
}
const baseName = safeExportFilenamePart(clientName, pub.slice(0, 12));
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.setHeader("Content-Disposition", `attachment; filename="amnezia-cascade-${baseName}.conf"`);
res.send(text);
} catch (e) {
console.error(e);
res.status(500).json({ error: String(e.message || e) });
}
});
app.post("/api/warp/start", requireAuth, async (req, res) => { app.post("/api/warp/start", requireAuth, async (req, res) => {
const rt = runtimeForRequest(req); const rt = runtimeForRequest(req);
if (!(await warpFileExists(rt, rt.profile.warpConf))) { if (!(await warpFileExists(rt, rt.profile.warpConf))) {