diff --git a/README.md b/README.md
index 932f23b..b2e5cfa 100644
--- a/README.md
+++ b/README.md
@@ -105,6 +105,10 @@ chmod +x scripts/warp-amnezia.sh
Футер с ссылками (**Amnezia Admin WebUI**, Boosty, Ozon СБП, Telegram) в админке находится **внизу страницы** — прокрутите ниже таблицы.
+### Каскад (свой Endpoint для клиента)
+
+В панели блок **«Новый клиент под каскад»**: задаёте IP/DNS и при необходимости порт — сервер **генерирует ключи**, добавляет peer в текущий инстанс AmneziaWG и отдаёт `.conf`, где **Endpoint** указывает на ваш промежуточный узел. На этом узле нужен **проброс UDP** на VPS (тот же порт, что слушает WG на сервере, либо свой порт из формы).
+
### Экспорт конфигурации клиента (.conf)
Если в записи клиента на сервере есть **`userData.last_config`** (JSON из приложения Amnezia с полем **`config`** — готовый текст — или с **`client_priv_key`** и ключами сервера), в таблице появятся **«Скачать .conf»**, **«Прямая ссылка»** и **«Копировать URL»**. Прямая ссылка имеет вид
@@ -113,7 +117,7 @@ chmod +x scripts/warp-amnezia.sh
Чтобы скачивать **без входа в панель**, задайте **`EXPORT_CONFIG_SECRET`** при установке и открывайте
`/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`).
diff --git a/public/app.js b/public/app.js
index bef3b06..f075d29 100644
--- a/public/app.js
+++ b/public/app.js
@@ -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;
diff --git a/public/index.html b/public/index.html
index 7eda1a2..d945acd 100644
--- a/public/index.html
+++ b/public/index.html
@@ -86,6 +86,8 @@
+
+
@@ -106,6 +108,32 @@
+
+
+
Новый клиент под каскад
+
+
+ Укажите Endpoint — публичный IP или DNS узла, куда клиент будет подключаться первым шагом
+ (промежуточный сервер, домашний роутер с пробросом порта и т.д.). Порт по умолчанию совпадает с ListenPort этого инстанса.
+ Клиент будет создан на текущем сервере AmneziaWG (новые ключи); IP в туннеле можно задать явно или оставить пустым — подберём свободный в той же подсети, что у остальных.
+