mirror of
https://github.com/andrey271192/amnezia_web-PRO.git
synced 2026-09-20 14:42:00 +00:00
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:
@@ -31,6 +31,8 @@ const pwNew = document.querySelector("#pw-new");
|
||||
const pwNew2 = document.querySelector("#pw-new2");
|
||||
const pwMsg = document.querySelector("#pw-msg");
|
||||
|
||||
const profileHintEl = document.querySelector("#profile-hint");
|
||||
|
||||
const dtDialog = document.querySelector("#disconnect-dt-dialog");
|
||||
const dtTitle = document.querySelector("#dt-dialog-title");
|
||||
const dtClientEl = document.querySelector("#dt-dialog-client");
|
||||
@@ -202,6 +204,15 @@ async function loadProtocols() {
|
||||
try {
|
||||
const data = await api("/api/protocols");
|
||||
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) {
|
||||
protoSwitch.classList.add("hidden");
|
||||
return;
|
||||
@@ -252,6 +263,11 @@ refreshBtn.addEventListener("click", () => {
|
||||
loadClients();
|
||||
});
|
||||
|
||||
const cascadeForm = document.querySelector("#cascade-form");
|
||||
if (cascadeForm) {
|
||||
cascadeForm.addEventListener("submit", (ev) => void downloadCascadeConf(ev));
|
||||
}
|
||||
|
||||
protoSelect.addEventListener("change", async () => {
|
||||
try {
|
||||
setStatus("Смена инстанса…", false);
|
||||
@@ -516,7 +532,7 @@ function renderRows(clients) {
|
||||
const exHint = document.createElement("p");
|
||||
exHint.className = "muted export-missing-hint";
|
||||
exHint.textContent =
|
||||
"Экспорт .conf с сервера недоступен: нет userData.last_config (ключи только в приложении Amnezia).";
|
||||
"Конфиг с сервера недоступен (нет last_config). Создайте клиента с нужным Endpoint в блоке «Новый клиент под каскад» ниже или возьмите ключ из приложения Amnezia.";
|
||||
nameWrap.appendChild(exHint);
|
||||
}
|
||||
nameTd.appendChild(nameWrap);
|
||||
@@ -728,10 +744,14 @@ function escapeHtml(s) {
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function currentProfileIdValue() {
|
||||
if (!protoSelect || !protoSwitch || protoSwitch.classList.contains("hidden")) return "";
|
||||
return String(protoSelect.value || "").trim();
|
||||
}
|
||||
|
||||
/** Query для нужного инстанса при нескольких профилях AWG_PROFILES */
|
||||
function currentProfileQuerySuffix() {
|
||||
if (!protoSelect || !protoSwitch || protoSwitch.classList.contains("hidden")) return "";
|
||||
const pid = String(protoSelect.value || "").trim();
|
||||
const pid = currentProfileIdValue();
|
||||
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 (1–65535).", 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) {
|
||||
const next = prompt(`Новое имя для «${c.name}»:`, c.name);
|
||||
if (next === null) return;
|
||||
|
||||
@@ -86,6 +86,8 @@
|
||||
<button type="button" id="refresh" class="btn primary">Обновить</button>
|
||||
</section>
|
||||
|
||||
<p id="profile-hint" class="profile-hint muted hidden" role="note"></p>
|
||||
|
||||
<p id="status" class="status" role="status"></p>
|
||||
|
||||
<section class="panel warp-panel" id="warp-panel" hidden>
|
||||
@@ -106,6 +108,32 @@
|
||||
</details>
|
||||
</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">
|
||||
<div class="panel-head">
|
||||
<h2>Пользователи</h2>
|
||||
|
||||
@@ -528,6 +528,35 @@ tr:last-child td {
|
||||
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 {
|
||||
margin-top: 1.25rem;
|
||||
color: var(--muted);
|
||||
|
||||
Reference in New Issue
Block a user