diff --git a/README.md b/README.md index 0dd6d61..932f23b 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ cd /opt/amnezia-admin && chmod +x scripts/install.sh && sudo SKIP_DOWNLOAD=1 bas | `CLIENT_CONFIG_ENDPOINT` | _(нет)_ | Публичный IP или DNS VPS для строки `Endpoint` при сборке `.conf` из `last_config`, если нет `hostName` в JSON и заходите в панель не по IP | | `CLIENT_EXPORT_DNS1` | `8.8.8.8` | DNS в экспортируемом клиентском конфиге | | `CLIENT_EXPORT_DNS2` | `8.8.4.4` | Второй DNS в экспортируемом конфиге | +| `EXPORT_CONFIG_SECRET` | _(нет)_ | Секрет для прямой ссылки без входа в панель: `GET /api/clients/export-config?token=СЕКРЕТ&clientId=…&profileId=…` (при нескольких инстансах `profileId` обязателен). Не светите URL посторонним | | `ADMIN_PASSWORD` | _(генерируется)_ | Первый пароль вместо файла | | `SKIP_DOWNLOAD` | `0` | `1` — не качать GitHub, собрать из `INSTALL_DIR` | | `ALLOW_DEFAULT_PASSWORD` | `0` | `1` — см. раздел «Пароль» ниже | @@ -68,6 +69,8 @@ cd /opt/amnezia-admin && chmod +x scripts/install.sh && sudo SKIP_DOWNLOAD=1 bas | `LANDING_CONTAINER` | `amnezia-web-landing` | Имя контейнера лендинга | | `NO_CACHE` | `0` | `1` — `docker build --no-cache` при проблемах с обновлением образа | +Переменная **`AWG_PROFILES`** при установке автоматически сохраняется в **`/root/amnezia-admin.awg-profiles.json`** на VPS; при следующем запуске `install.sh` без `AWG_PROFILES` значение подставляется из этого файла или из **старого контейнера** `amnezia-admin` перед его удалением — так переключатель «Инстанс» не пропадает после обновления панели. + #### Несколько инстансов (AmneziaWG + Legacy и т.д.) Пути и имена контейнеров на сервере могут отличаться — проверьте внутри контейнера (`docker exec … ls /opt/amnezia`). Пример **двух** профилей при запуске установщика (одна строка JSON в кавычках): @@ -104,7 +107,13 @@ chmod +x scripts/warp-amnezia.sh ### Экспорт конфигурации клиента (.conf) -Если в записи клиента на сервере есть **`userData.last_config`** (JSON из приложения Amnezia с полем **`config`** — готовый текст — или с **`client_priv_key`** и ключами сервера), в таблице появится кнопка **«Скачать .conf»**. Такое содержимое сервер получает, когда клиент создавался/синхронизировался через официальное приложение; если на VPS только «голый» `clientsTable` без `last_config`, кнопки не будет — конфиг нужно брать из приложения на устройстве. +Если в записи клиента на сервере есть **`userData.last_config`** (JSON из приложения Amnezia с полем **`config`** — готовый текст — или с **`client_priv_key`** и ключами сервера), в таблице появятся **«Скачать .conf»**, **«Прямая ссылка»** и **«Копировать URL»**. Прямая ссылка имеет вид +`/api/clients/export-config?clientId=…&profileId=…` — последний параметр нужен только при нескольких инстансах (`AWG_PROFILES`). Работает в браузере, где вы уже вошли в панель (cookie-сессия). + +Чтобы скачивать **без входа в панель**, задайте **`EXPORT_CONFIG_SECRET`** при установке и открывайте +`/api/clients/export-config?token=ВАШ_СЕКРЕТ&clientId=…` — при **нескольких** профилях добавьте **`&profileId=…`**. Не пересылайте такую ссылку третьим лицам. + +Если на 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 141ffdf..bef3b06 100644 --- a/public/app.js +++ b/public/app.js @@ -512,6 +512,13 @@ function renderRows(clients) { btn("Переименовать", "btn small ghost", () => void renameClient(c)) ); nameWrap.append(strong, renameWrap); + if (!c.exportAvailable) { + const exHint = document.createElement("p"); + exHint.className = "muted export-missing-hint"; + exHint.textContent = + "Экспорт .conf с сервера недоступен: нет userData.last_config (ключи только в приложении Amnezia)."; + nameWrap.appendChild(exHint); + } nameTd.appendChild(nameWrap); const ipTd = document.createElement("td"); @@ -549,8 +556,21 @@ function renderRows(clients) { ); } if (c.exportAvailable) { + const direct = document.createElement("a"); + direct.className = "btn small ghost"; + direct.href = clientExportGetUrl(c.clientId); + direct.textContent = "Прямая ссылка"; + direct.rel = "noopener"; + direct.title = + "Открыть в новой вкладке — скачается .conf, если вы авторизованы в этой панели (cookies)."; + + actTd.appendChild(btn("Скачать .conf", "btn small ghost", () => void downloadClientConfig(c))); + actTd.appendChild(direct); actTd.appendChild( - btn("Скачать .conf", "btn small ghost", () => void downloadClientConfig(c)), + btn("Копировать URL", "btn small ghost", async () => { + const ok = await copyTextToClipboard(clientExportGetUrl(c.clientId)); + setStatus(ok ? "Ссылка скопирована (вставьте в браузер, будучи залогиненным)." : "Не удалось скопировать.", !ok); + }), ); } actTd.appendChild(btn("Удалить", "btn small warn", () => confirmDelete(c.name, c.clientId))); @@ -708,14 +728,46 @@ function escapeHtml(s) { .replace(/"/g, """); } +/** Query для нужного инстанса при нескольких профилях AWG_PROFILES */ +function currentProfileQuerySuffix() { + if (!protoSelect || !protoSwitch || protoSwitch.classList.contains("hidden")) return ""; + const pid = String(protoSelect.value || "").trim(); + return pid ? `&profileId=${encodeURIComponent(pid)}` : ""; +} + +/** Прямая GET-ссылка на скачивание (работает в браузере с активной сессией панели). */ +function clientExportGetUrl(clientId) { + const q = `clientId=${encodeURIComponent(clientId)}${currentProfileQuerySuffix()}`; + return `${window.location.origin}/api/clients/export-config?${q}`; +} + +async function copyTextToClipboard(text) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + try { + const ta = document.createElement("textarea"); + ta.value = text; + ta.style.position = "fixed"; + ta.style.left = "-9999px"; + document.body.appendChild(ta); + ta.select(); + document.execCommand("copy"); + ta.remove(); + return true; + } catch { + return false; + } + } +} + async function downloadClientConfig(c) { try { setStatus("Готовлю конфиг…", false); - const res = await fetch("/api/clients/export-config", { - method: "POST", + const res = await fetch(clientExportGetUrl(c.clientId), { + method: "GET", credentials: "same-origin", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ clientId: c.clientId }), }); const text = await res.text(); if (!res.ok) { diff --git a/public/index.html b/public/index.html index 8ceb67f..7eda1a2 100644 --- a/public/index.html +++ b/public/index.html @@ -29,7 +29,7 @@

Панель сервера

Пользователи AmneziaWG

-

Дату отключения задаёте сами (локальное время браузера). Кнопка «Выключить» убирает клиента из туннеля сразу. «Задать дату» без галочки — только запись в таблице; с галочкой — клиент остаётся в туннеле до наступления времени. Несколько инстансов AmneziaWG (Legacy и др.) — в переменной AWG_PROFILES и списке «Инстанс».

+

Дату отключения задаёте сами (локальное время браузера). Кнопка «Выключить» убирает клиента из туннеля сразу. «Задать дату» без галочки — только запись в таблице; с галочкой — клиент остаётся в туннеле до наступления времени. Несколько инстансов задаются переменной AWG_PROFILES; установщик при апдейте подставляет её из прежнего контейнера или из /root/amnezia-admin.awg-profiles.json. Конфиг .conf — только если на сервере есть last_config; иначе используйте приложение Amnezia.

diff --git a/public/styles.css b/public/styles.css index 920114d..3b80ff2 100644 --- a/public/styles.css +++ b/public/styles.css @@ -521,6 +521,13 @@ tr:last-child td { flex-wrap: wrap; } +.export-missing-hint { + margin: 0.35rem 0 0; + font-size: 0.72rem; + line-height: 1.35; + max-width: 22rem; +} + .raw { margin-top: 1.25rem; color: var(--muted); diff --git a/scripts/install.sh b/scripts/install.sh index db11606..b867310 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -82,6 +82,33 @@ else echo "→ Первый пароль записан в ${PASS_FILE}" fi +# AWG_PROFILES: не терять при апдейте без переменной (пропадает список «Инстанс»). +AWG_PROFILE_SNAPSHOT="/root/amnezia-admin.awg-profiles.json" +if [[ -n "${AWG_PROFILES:-}" ]]; then + umask 077 + printf '%s\n' "${AWG_PROFILES}" >"${AWG_PROFILE_SNAPSHOT}" 2>/dev/null || true +elif docker inspect "${CONTAINER_NAME}" >/dev/null 2>&1; then + PREV_AWG_PROFILES="" + while IFS= read -r __env_line; do + if [[ "${__env_line}" == AWG_PROFILES=* ]]; then + PREV_AWG_PROFILES="${__env_line#AWG_PROFILES=}" + break + fi + done < <(docker inspect "${CONTAINER_NAME}" --format '{{range .Config.Env}}{{println .}}{{end}}') + if [[ -n "${PREV_AWG_PROFILES}" ]]; then + AWG_PROFILES="${PREV_AWG_PROFILES}" + echo "→ AWG_PROFILES восстановлен из предыдущего контейнера ${CONTAINER_NAME}." + umask 077 + printf '%s\n' "${AWG_PROFILES}" >"${AWG_PROFILE_SNAPSHOT}" 2>/dev/null || true + fi +fi +if [[ -z "${AWG_PROFILES:-}" ]] && [[ -f "${AWG_PROFILE_SNAPSHOT}" ]]; then + AWG_PROFILES="$(tr -d '\r\n' <"${AWG_PROFILE_SNAPSHOT}" || true)" + if [[ -n "${AWG_PROFILES}" ]]; then + echo "→ AWG_PROFILES восстановлен из ${AWG_PROFILE_SNAPSHOT}." + fi +fi + DOCKER_BUILD_EXTRA=() if [[ "${NO_CACHE:-}" == "1" ]]; then DOCKER_BUILD_EXTRA+=(--no-cache) @@ -119,7 +146,7 @@ for __warp_var in WARP_DIR WARP_CONF_PATH WARP_CLIENTS_LIST AMNEZIA_START_SCRIPT fi done -for __export_var in CLIENT_CONFIG_ENDPOINT CLIENT_EXPORT_DNS1 CLIENT_EXPORT_DNS2; do +for __export_var in CLIENT_CONFIG_ENDPOINT CLIENT_EXPORT_DNS1 CLIENT_EXPORT_DNS2 EXPORT_CONFIG_SECRET; do if [[ -n "${!__export_var:-}" ]]; then RUN_ENV+=( -e "${__export_var}=${!__export_var}" ) fi diff --git a/server.js b/server.js index 716cd2b..5697325 100644 --- a/server.js +++ b/server.js @@ -10,6 +10,8 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PORT = Number(process.env.PORT || 3980); const PROFILE_COOKIE = "amnezia_prof"; const SCHEDULER_MS = Number(process.env.SCHEDULE_DISCONNECT_MS || 60_000); +/** Если задан, разрешает GET /api/clients/export-config?token=…&clientId=… без сессии (храните секрет только для себя). */ +const EXPORT_CONFIG_SECRET = process.env.EXPORT_CONFIG_SECRET?.trim(); function parseProfilesFromEnv() { const raw = process.env.AWG_PROFILES?.trim(); @@ -264,6 +266,38 @@ function requireAuth(req, res, next) { next(); } +function verifyExportQueryToken(token) { + if (!EXPORT_CONFIG_SECRET || typeof token !== "string" || !token) return false; + const a = Buffer.from(token, "utf8"); + const b = Buffer.from(EXPORT_CONFIG_SECRET, "utf8"); + if (a.length !== b.length) return false; + try { + return crypto.timingSafeEqual(a, b); + } catch { + return false; + } +} + +function requireAuthOrExportToken(req, res, next) { + if (req.method === "GET" && verifyExportQueryToken(typeof req.query.token === "string" ? req.query.token : "")) { + next(); + return; + } + requireAuth(req, res, next); +} + +function runtimeFromExportRequest(req) { + const qPid = typeof req.query.profileId === "string" ? req.query.profileId.trim() : ""; + const bodyPid = + req.method === "POST" && typeof req.body?.profileId === "string" ? req.body.profileId.trim() : ""; + const pid = qPid || bodyPid; + if (pid) { + const p = PROFILES.find((x) => x.id === pid); + if (p) return createRuntime(p); + } + return runtimeForRequest(req); +} + function execDocker(args, stdin = null) { return new Promise((resolve, reject) => { const child = spawn("docker", args, { stdio: ["pipe", "pipe", "pipe"] }); @@ -1344,11 +1378,38 @@ app.get("/api/clients", requireAuth, async (req, res) => { } }); -app.post("/api/clients/export-config", requireAuth, async (req, res) => { - const rt = runtimeForRequest(req); - const clientId = req.body?.clientId; - if (!clientId || typeof clientId !== "string") { - res.status(400).json({ error: "Укажите clientId" }); +async function serveClientConfigExport(req, res) { + const tokenOk = + req.method === "GET" && + verifyExportQueryToken(typeof req.query.token === "string" ? req.query.token : ""); + + let rt; + if (tokenOk) { + if (PROFILES.length > 1) { + const pid = typeof req.query.profileId === "string" ? req.query.profileId.trim() : ""; + const p = PROFILES.find((x) => x.id === pid); + if (!p) { + res.status(400).json({ + error: + "При нескольких инстансах укажите в URL параметр profileId (как в списке «Инстанс» в панели).", + }); + return; + } + rt = createRuntime(p); + } else { + rt = createRuntime(PROFILES[0]); + } + } else { + rt = runtimeFromExportRequest(req); + } + + const rawId = + req.method === "POST" + ? req.body?.clientId + : req.query.clientId ?? req.query.id; + const clientId = typeof rawId === "string" ? decodeURIComponent(rawId.trim()) : ""; + if (!clientId) { + res.status(400).json({ error: "Укажите clientId (в теле POST или query GET)" }); return; } try { @@ -1383,6 +1444,14 @@ app.post("/api/clients/export-config", requireAuth, async (req, res) => { console.error(e); res.status(500).json({ error: String(e.message || e) }); } +} + +app.get("/api/clients/export-config", requireAuthOrExportToken, (req, res) => { + void serveClientConfigExport(req, res); +}); + +app.post("/api/clients/export-config", requireAuth, (req, res) => { + void serveClientConfigExport(req, res); }); app.post("/api/warp/start", requireAuth, async (req, res) => {