commit ede0c66415f950106c59c16ab492cb9259ca8265 Author: Андрей Бобырев Date: Thu May 14 15:40:20 2026 +0300 docs: publish install/uninstall scripts, README, FUNDING, support footer Add curl-one-liner install with generated password file; uninstall flags for image/data/src; optional ALLOW_DEFAULT_PASSWORD; footer mirroring GitHub/Boosty/Telegram layout. Co-authored-by: Cursor diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..93f1361 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +node_modules +npm-debug.log diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..607bf15 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,8 @@ +# Способы поддержки автора (GitHub показывает кнопку Sponsor на базе github:). +# Прямые ссылки см. README → «Поддержка проекта» и футер веб-панели. + +github: andrey271192 + +custom: + # Boosty (пример из интерфейса проекта; замените на свой профиль при необходимости) + - https://boosty.to/lot_andrey diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b1d9ed3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +npm-debug.log* +.DS_Store +*.swp +.env +.env.* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..050c602 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM node:22-alpine + +RUN apk add --no-cache docker-cli + +RUN mkdir -p /data && chmod 700 /data + +WORKDIR /app + +COPY package.json ./ +RUN npm install --omit=dev + +COPY server.js ./server.js +COPY public ./public + +ENV NODE_ENV=production +ENV PORT=3980 + +EXPOSE 3980 + +CMD ["node", "server.js"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..14fac91 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ffcdb67 --- /dev/null +++ b/README.md @@ -0,0 +1,131 @@ +# Amnezia Admin WebUI + +Веб-панель на вашем VPS для управления клиентами **AmneziaWG**: вкл/выкл, удаление, переименование, дата отключения. Работает через Docker и `docker exec` в контейнер **Amnezia** (по умолчанию `amnezia-awg2`). + +**Безопасность:** контейнер с монтированием `docker.sock` эквивалентен root на хосте — используйте сложный пароль и по возможности ограничьте доступ по IP или TLS. + +--- + +## Установка одной командой + +На сервере под **root** (или через `sudo`), когда репозиторий уже опубликован на GitHub: + +```bash +curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia-admin/main/scripts/install.sh | sudo bash +``` + +Другой репозиторий или ветка: + +```bash +GITHUB_REPO=ваш/форк BRANCH=main curl -fsSL https://raw.githubusercontent.com/ваш/форк/main/scripts/install.sh | sudo bash +``` + +Уже скачали проект вручную в `/opt/amnezia-admin`: + +```bash +cd /opt/amnezia-admin && chmod +x scripts/install.sh && sudo SKIP_DOWNLOAD=1 bash scripts/install.sh +``` + +Переменные установки (необязательно): + +| Переменная | По умолчанию | Назначение | +|------------|--------------|------------| +| `GITHUB_REPO` | `andrey271192/amnezia-admin` | Откуда качать архив | +| `BRANCH` | `main` | Ветка | +| `INSTALL_DIR` | `/opt/amnezia-admin` | Куда распаковать исходники | +| `DATA_DIR` | `/opt/amnezia-admin-data` | Том с `password.hash` и сессией | +| `HOST_PORT` | `8080` | Порт HTTP панели на хосте | +| `AWG_CONTAINER` | `amnezia-awg2` | Имя контейнера Amnezia WG | +| `ADMIN_PASSWORD` | _(генерируется)_ | Первый пароль вместо файла | +| `SKIP_DOWNLOAD` | `0` | `1` — не качать GitHub, собрать из `INSTALL_DIR` | +| `ALLOW_DEFAULT_PASSWORD` | `0` | `1` — см. раздел «Пароль» ниже | + +После установки откройте `http://IP_СЕРВЕРА:8080`. + +--- + +## Удаление одной командой + +```bash +curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia-admin/main/scripts/uninstall.sh | sudo bash +``` + +Полная очистка (контейнер, образ, данные панели и каталог `/opt/amnezia-admin`): + +```bash +curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia-admin/main/scripts/uninstall.sh | sudo REMOVE_IMAGE=1 REMOVE_DATA=1 REMOVE_SRC=1 bash +``` + +--- + +## Первый вход и пароль + +### Режим по умолчанию (рекомендуется) + +Скрипт установки **генерирует** пароль и записывает его в файл на сервере: + +```bash +sudo cat /root/amnezia-admin.initial-password +``` + +Войдите в панель этим паролем и сразу смените его в блоке **«Сменить пароль»**. + +### Свой пароль при установке + +```bash +ADMIN_PASSWORD='ВашНадёжныйПароль' curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia-admin/main/scripts/install.sh | sudo -E bash +``` + +После первого успешного старта переменную `ADMIN_PASSWORD` из команды `docker run` убирайте — пароль уже в томе `DATA_DIR`. + +### Пароль по умолчанию из документации (только тест / лаборатория) + +Если задать при установке **`ALLOW_DEFAULT_PASSWORD=1`**, контейнер создаёт пароль из README: + +- **Логин в веб:** пароль по умолчанию **`AmneziaAdmin!ChangeMe`** + +Его можно переопределить переменной **`DEFAULT_ADMIN_PASSWORD`** в окружении контейнера до первого создания `password.hash`. + +На продакшене этот режим **не рекомендуется**. + +### Ручной Docker без скрипта + +Нужны том `-v /путь/данных:/data` и **один из** вариантов: + +1. `-e ADMIN_PASSWORD=...` при **первом** запуске (файла `password.hash` ещё нет); +2. `-e ALLOW_DEFAULT_PASSWORD=1` — см. пароль выше; +3. готовый файл `password.hash` в томе (продвинутый сценарий). + +--- + +## Разработка и локальный запуск + +```bash +npm install +ADMIN_PASSWORD=localtest node server.js +``` + +Нужны Docker и контейнер Amnezia на той же машине (или проброс `DOCKER_HOST`). + +--- + +## Support links + +Поддержать проект — поставь звезду на GitHub или донат. Связаться с автором — Telegram. + +| Способ | Ссылка | +|--------|--------| +| GitHub | [репозиторий](https://github.com/andrey271192/amnezia-admin) | +| Boosty | [boosty.to/lot_andrey](https://boosty.to/lot_andrey) | +| Ozon СБП | _добавьте свою постоянную ссылку СБП сюда и при желании замените цель ссылки «Ozon СБП» в `public/index.html`_ | +| Telegram | [@lot_andrey](https://t.me/lot_andrey) | + +Файл [`.github/FUNDING.yml`](.github/FUNDING.yml) задаёт кнопку **Sponsor** на GitHub (см. [документацию GitHub](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository)). + +Тот же блок поддержки продублирован в **футере веб-интерфейса** (как в эталонном макете: название · GitHub · Boosty · Ozon СБП · Telegram). + +--- + +## Лицензия + +MIT, см. [LICENSE](LICENSE). diff --git a/package.json b/package.json new file mode 100644 index 0000000..ff3bde6 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "amnezia-admin", + "version": "1.0.0", + "private": true, + "description": "Web-панель управления клиентами AmneziaWG (Docker, пароль, AWG)", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/andrey271192/amnezia-admin.git" + }, + "type": "module", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "express": "^4.21.2" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..1ae714f --- /dev/null +++ b/public/app.js @@ -0,0 +1,381 @@ +const loginGate = document.querySelector("#login-gate"); +const appRoot = document.querySelector("#app-root"); +const loginForm = document.querySelector("#login-form"); +const loginPassword = document.querySelector("#login-password"); +const loginError = document.querySelector("#login-error"); + +const logoutBtn = document.querySelector("#logout"); +const refreshBtn = document.querySelector("#refresh"); +const rowsEl = document.querySelector("#rows"); +const statusEl = document.querySelector("#status"); +const peerCountEl = document.querySelector("#peer-count"); +const wgShowEl = document.querySelector("#wg-show"); + +const pwForm = document.querySelector("#pw-form"); +const pwCurrent = document.querySelector("#pw-current"); +const pwNew = document.querySelector("#pw-new"); +const pwNew2 = document.querySelector("#pw-new2"); +const pwMsg = document.querySelector("#pw-msg"); + +const dtDialog = document.querySelector("#disconnect-dt-dialog"); +const dtTitle = document.querySelector("#dt-dialog-title"); +const dtClientEl = document.querySelector("#dt-dialog-client"); +const dtInput = document.querySelector("#dt-dialog-input"); +const dtCancel = document.querySelector("#dt-dialog-cancel"); +const dtOk = document.querySelector("#dt-dialog-ok"); + +let dtMode = "disable"; +/** @type {{ clientId: string, name: string } | null} */ +let dtClient = null; + +function isoToDatetimeLocal(iso) { + if (!iso) return ""; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ""; + const pad = (n) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +function datetimeLocalToIso(localVal) { + if (!localVal || !String(localVal).trim()) { + throw new Error("Укажите дату и время"); + } + const d = new Date(localVal); + if (Number.isNaN(d.getTime())) { + throw new Error("Некорректная дата"); + } + return d.toISOString(); +} + +function openDisableDialog(c) { + dtMode = "disable"; + dtClient = c; + dtTitle.textContent = "Выключить клиента"; + dtClientEl.textContent = c.name; + dtOk.textContent = "Выключить"; + dtInput.value = isoToDatetimeLocal(new Date().toISOString()); + dtDialog.showModal(); +} + +function openEditDisconnectDialog(c) { + dtMode = "edit"; + dtClient = c; + dtTitle.textContent = "Дата последнего отключения"; + dtClientEl.textContent = c.name; + dtOk.textContent = "Сохранить"; + const iso = + c.lastDisconnectedAt || (!c.activeInConf && c.disabledAt) || new Date().toISOString(); + dtInput.value = isoToDatetimeLocal(iso); + dtDialog.showModal(); +} + +dtCancel.addEventListener("click", () => { + dtDialog.close(); + dtClient = null; +}); + +dtOk.addEventListener("click", async () => { + if (!dtClient) return; + let iso; + try { + iso = datetimeLocalToIso(dtInput.value); + } catch (e) { + setStatus(String(e.message || e), true); + return; + } + try { + if (dtMode === "disable") { + setStatus("Выполняю…", false); + await api("/api/clients/disable", { + method: "POST", + body: JSON.stringify({ clientId: dtClient.clientId, disconnectedAt: iso }), + }); + } else { + setStatus("Сохраняю дату…", false); + await api("/api/clients/disconnect-date", { + method: "POST", + body: JSON.stringify({ clientId: dtClient.clientId, disconnectedAt: iso }), + }); + } + dtDialog.close(); + dtClient = null; + setStatus("Готово.", false); + await loadClients(); + } catch (e) { + setStatus(String(e.message || e), true); + } +}); + +function showLogin() { + loginGate.classList.remove("hidden"); + loginGate.setAttribute("aria-hidden", "false"); + appRoot.classList.add("hidden"); +} + +function showApp() { + loginGate.classList.add("hidden"); + loginGate.setAttribute("aria-hidden", "true"); + appRoot.classList.remove("hidden"); +} + +function setStatus(text, isErr) { + statusEl.textContent = text || ""; + statusEl.classList.toggle("err", Boolean(isErr)); +} + +function setPwMsg(text, isErr) { + pwMsg.textContent = text || ""; + pwMsg.classList.toggle("err", Boolean(isErr)); +} + +async function api(path, opts = {}) { + const res = await fetch(path, { + ...opts, + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + ...opts.headers, + }, + }); + const text = await res.text(); + let data; + try { + data = text ? JSON.parse(text) : {}; + } catch { + data = { raw: text }; + } + if (!res.ok) { + const msg = data.error || data.raw || res.statusText; + throw new Error(msg); + } + return data; +} + +async function checkSession() { + try { + await api("/api/session"); + return true; + } catch { + return false; + } +} + +loginForm.addEventListener("submit", async (ev) => { + ev.preventDefault(); + loginError.textContent = ""; + try { + await api("/api/login", { + method: "POST", + body: JSON.stringify({ password: loginPassword.value }), + }); + loginPassword.value = ""; + showApp(); + await loadClients(); + } catch (e) { + loginError.textContent = String(e.message || e); + } +}); + +logoutBtn.addEventListener("click", async () => { + try { + await api("/api/logout", { method: "POST", body: JSON.stringify({}) }); + } catch { + /* ignore */ + } + showLogin(); + loginPassword.focus(); +}); + +refreshBtn.addEventListener("click", () => { + loadClients(); +}); + +const dtRu = new Intl.DateTimeFormat("ru-RU", { + dateStyle: "short", + timeStyle: "short", +}); + +function formatLastDisconnect(c) { + const iso = + c.lastDisconnectedAt || + (!c.activeInConf && c.disabledAt) || + null; + if (!iso) return "—"; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return "—"; + return dtRu.format(d); +} + +pwForm.addEventListener("submit", async (ev) => { + ev.preventDefault(); + setPwMsg("", false); + if (pwNew.value !== pwNew2.value) { + setPwMsg("Новый пароль и повтор не совпадают.", true); + return; + } + try { + const data = await api("/api/change-password", { + method: "POST", + body: JSON.stringify({ + currentPassword: pwCurrent.value, + newPassword: pwNew.value, + }), + }); + pwCurrent.value = ""; + pwNew.value = ""; + pwNew2.value = ""; + setPwMsg(data.message || "Готово.", false); + showLogin(); + loginPassword.focus(); + } catch (e) { + setPwMsg(String(e.message || e), true); + } +}); + +function renderRows(clients) { + rowsEl.innerHTML = ""; + clients.forEach((c) => { + const tr = document.createElement("tr"); + + const nameTd = document.createElement("td"); + const nameWrap = document.createElement("div"); + nameWrap.className = "name-cell"; + const strong = document.createElement("strong"); + strong.textContent = c.name; + const renameWrap = document.createElement("div"); + renameWrap.className = "rename-inline"; + renameWrap.appendChild( + btn("Переименовать", "btn small ghost", () => void renameClient(c)) + ); + nameWrap.append(strong, renameWrap); + nameTd.appendChild(nameWrap); + + const ipTd = document.createElement("td"); + ipTd.innerHTML = `${escapeHtml(c.allowedIps || "—")}`; + + const stTd = document.createElement("td"); + const badge = document.createElement("span"); + badge.className = `badge ${c.activeInConf ? "on" : "off"}`; + badge.textContent = c.activeInConf ? "В туннеле" : "Выключен"; + stTd.appendChild(badge); + + const offTd = document.createElement("td"); + offTd.className = "date-cell"; + const dateLine = document.createElement("div"); + dateLine.textContent = formatLastDisconnect(c); + const dtWrap = document.createElement("div"); + dtWrap.className = "rename-inline"; + dtWrap.appendChild( + btn("Задать дату", "btn small ghost", () => openEditDisconnectDialog(c)) + ); + offTd.append(dateLine, dtWrap); + + const actTd = document.createElement("td"); + actTd.className = "actions"; + + if (c.activeInConf) { + actTd.appendChild(btn("Выключить", "btn small ghost", () => openDisableDialog(c))); + } else { + actTd.appendChild( + btn("Включить", "btn small primary", () => mutate("/api/clients/enable", c.clientId)) + ); + } + actTd.appendChild(btn("Удалить", "btn small warn", () => confirmDelete(c.name, c.clientId))); + + tr.append(nameTd, ipTd, stTd, offTd, actTd); + rowsEl.appendChild(tr); + }); +} + +function btn(label, cls, onClick) { + const b = document.createElement("button"); + b.type = "button"; + b.className = cls; + b.textContent = label; + b.addEventListener("click", onClick); + return b; +} + +function escapeHtml(s) { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +async function renameClient(c) { + const next = prompt(`Новое имя для «${c.name}»:`, c.name); + if (next === null) return; + const trimmed = next.trim().replace(/\s+/g, " "); + if (!trimmed) { + setStatus("Имя не может быть пустым", true); + return; + } + try { + setStatus("Сохраняю имя…", false); + await api("/api/clients/rename", { + method: "POST", + body: JSON.stringify({ clientId: c.clientId, name: trimmed }), + }); + setStatus("Готово.", false); + await loadClients(); + } catch (e) { + setStatus(String(e.message || e), true); + } +} + +async function mutate(path, clientId) { + try { + setStatus("Выполняю…", false); + await api(path, { method: "POST", body: JSON.stringify({ clientId }) }); + setStatus("Готово.", false); + await loadClients(); + } catch (e) { + setStatus(String(e.message || e), true); + } +} + +async function confirmDelete(name, clientId) { + const ok = confirm( + `Удалить клиента «${name}»? Конфиг из приложения Amnezia перестанет совпадать с сервером.` + ); + if (!ok) return; + await mutate("/api/clients/delete", clientId); +} + +async function loadClients() { + try { + setStatus("Загрузка…", false); + const data = await api("/api/clients"); + peerCountEl.textContent = `${data.clients.length} в таблице · ${data.peerCount} peer в awg0.conf`; + wgShowEl.textContent = data.wgShow || ""; + renderRows(data.clients); + setStatus("", false); + } catch (e) { + const msg = String(e.message || e); + if (msg.includes("Unauthorized")) { + showLogin(); + setStatus("", false); + loginError.textContent = "Сессия истекла — войдите снова."; + return; + } + setStatus(msg, true); + rowsEl.innerHTML = ""; + wgShowEl.textContent = ""; + peerCountEl.textContent = ""; + } +} + +async function boot() { + const ok = await checkSession(); + if (ok) { + showApp(); + await loadClients(); + } else { + showLogin(); + loginPassword.focus(); + } +} + +boot(); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..c8caf0b --- /dev/null +++ b/public/index.html @@ -0,0 +1,120 @@ + + + + + + AmneziaWG — клиенты + + + + + + + + + + + + + +
+

Дата отключения

+

+ + +
+ + +
+
+
+ + + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..8b5de3b --- /dev/null +++ b/public/styles.css @@ -0,0 +1,501 @@ +:root { + --bg: #070b10; + --card: #111722; + --line: rgba(255, 255, 255, 0.08); + --text: #f4f7ff; + --muted: #94a3b8; + --accent: #7dd3fc; + --danger: #fb7185; + --ok: #4ade80; + --shadow: 0 24px 80px rgba(0, 0, 0, 0.45); + font-family: "DM Sans", system-ui, sans-serif; +} + +* { + box-sizing: border-box; +} + +body.page { + margin: 0; + background: radial-gradient(circle at 20% 20%, rgba(125, 211, 252, 0.08), transparent 35%), + radial-gradient(circle at 80% 0%, rgba(94, 234, 212, 0.06), transparent 40%), + var(--bg); + color: var(--text); + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.shell { + flex: 1 0 auto; + max-width: 960px; + margin: 0 auto; + padding: clamp(1.5rem, 4vw, 2.5rem) clamp(1rem, 3vw, 1.5rem) 3rem; +} + +.top { + display: grid; + gap: 1.25rem; + grid-template-columns: 1fr; +} + +@media (min-width: 880px) { + .top { + grid-template-columns: 2fr 1fr; + align-items: start; + } +} + +.eyebrow { + letter-spacing: 0.18em; + text-transform: uppercase; + font-size: 0.72rem; + color: var(--accent); + margin: 0 0 0.35rem; +} + +h1 { + margin: 0 0 0.5rem; + font-size: clamp(1.6rem, 4vw, 2rem); +} + +.sub { + margin: 0; + color: var(--muted); + line-height: 1.55; + max-width: 62ch; +} + +.token-box { + border: 1px solid var(--line); + border-radius: 14px; + padding: 1rem; + background: #0c121b; +} + +.token-box label { + display: block; + font-size: 0.85rem; + color: var(--muted); + margin-bottom: 0.35rem; +} + +.token-hint { + margin: 0 0 0.65rem; + font-size: 0.82rem; + line-height: 1.45; + color: var(--muted); +} + +.token-hint code.inline { + font-family: "JetBrains Mono", ui-monospace, monospace; + font-size: 0.78rem; + padding: 0.1rem 0.35rem; + border-radius: 6px; + background: rgba(0, 0, 0, 0.35); + border: 1px solid var(--line); +} + +.hidden { + display: none !important; +} + +.gate { + flex: 1; + min-height: 100vh; + display: grid; + place-items: center; + padding: 2rem 1rem; +} + +.gate-card { + width: min(420px, 100%); + border: 1px solid var(--line); + border-radius: 16px; + padding: 1.35rem 1.25rem 1.5rem; + background: var(--card); + box-shadow: var(--shadow); +} + +.gate-card h1 { + margin: 0 0 0.35rem; + font-size: 1.35rem; +} + +.gate-card .sub { + margin: 0 0 1rem; +} + +.gate-card label { + display: block; + font-size: 0.85rem; + color: var(--muted); + margin-bottom: 0.35rem; +} + +.gate-card input { + width: 100%; + padding: 0.65rem 0.75rem; + border-radius: 10px; + border: 1px solid var(--line); + background: #0a0f16; + color: var(--text); + font: inherit; + margin-bottom: 0.75rem; +} + +.btn.full { + width: 100%; + margin-top: 0.25rem; +} + +.session-actions { + margin-bottom: 0.75rem; +} + +.pw-change { + margin-top: 0.25rem; + color: var(--muted); + font-size: 0.88rem; +} + +.pw-change summary { + cursor: pointer; + color: var(--accent); + font-weight: 600; +} + +.pw-change form { + margin-top: 0.75rem; + display: grid; + gap: 0.35rem; +} + +.pw-change label { + font-size: 0.8rem; + color: var(--muted); +} + +.pw-change input { + width: 100%; + padding: 0.55rem 0.65rem; + border-radius: 10px; + border: 1px solid var(--line); + background: #0a0f16; + color: var(--text); + font: inherit; +} + +.token-box input { + width: 100%; + padding: 0.65rem 0.75rem; + border-radius: 10px; + border: 1px solid var(--line); + background: #0a0f16; + color: var(--text); + font-family: "JetBrains Mono", ui-monospace, monospace; + font-size: 0.85rem; +} + +.toolbar { + margin-top: 1.25rem; + display: flex; + gap: 0.75rem; + align-items: center; + flex-wrap: wrap; +} + +.pill { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.45rem 0.75rem; + border-radius: 999px; + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.03); + font-size: 0.9rem; +} + +.dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--muted); +} + +.dot.ok { + background: var(--ok); +} + +.btn { + border: 1px solid transparent; + border-radius: 11px; + padding: 0.55rem 0.95rem; + font-weight: 600; + cursor: pointer; + font: inherit; +} + +.btn.primary { + background: linear-gradient(135deg, #38bdf8, #22d3ee); + color: #041018; +} + +.btn.ghost { + border-color: var(--line); + background: transparent; + color: var(--text); +} + +.token-box .btn.ghost { + margin-top: 0.5rem; + width: 100%; +} + +.rename-inline .btn.small { + width: auto; +} + +.btn.warn { + border-color: rgba(251, 113, 133, 0.35); + background: rgba(251, 113, 133, 0.08); + color: #fecdd3; +} + +.btn.small { + padding: 0.35rem 0.65rem; + font-size: 0.82rem; +} + +.status { + min-height: 1.25rem; + color: var(--muted); + margin: 0.75rem 0 0; +} + +.status.err { + color: #fecaca; +} + +.panel { + margin-top: 1rem; + border: 1px solid var(--line); + border-radius: 16px; + background: var(--card); + overflow: hidden; +} + +.panel-head { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 1rem; + padding: 1rem 1.1rem; + border-bottom: 1px solid var(--line); +} + +.panel-head h2 { + margin: 0; + font-size: 1rem; +} + +.muted { + color: var(--muted); + font-size: 0.88rem; +} + +.table-wrap { + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 0.95rem; +} + +th, +td { + padding: 0.85rem 1rem; + border-bottom: 1px solid var(--line); + text-align: left; + vertical-align: middle; +} + +th { + color: var(--muted); + font-weight: 600; + font-size: 0.78rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +tr:last-child td { + border-bottom: none; +} + +.name-cell strong { + font-weight: 600; +} + +.name-cell .rename-inline { + margin-top: 0.35rem; +} + +.date-cell { + font-size: 0.88rem; + color: var(--muted); + white-space: nowrap; +} + +.ip { + font-family: "JetBrains Mono", ui-monospace, monospace; + font-size: 0.85rem; + color: #dbeafe; +} + +.badge { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.25rem 0.55rem; + border-radius: 999px; + font-size: 0.82rem; + border: 1px solid var(--line); +} + +.badge.on { + border-color: rgba(74, 222, 128, 0.35); + color: #bbf7d0; + background: rgba(74, 222, 128, 0.08); +} + +.badge.off { + border-color: rgba(148, 163, 184, 0.35); + color: #e2e8f0; + background: rgba(148, 163, 184, 0.06); +} + +.actions { + display: flex; + gap: 0.35rem; + justify-content: flex-end; + flex-wrap: wrap; +} + +.raw { + margin-top: 1.25rem; + color: var(--muted); +} + +.raw summary { + cursor: pointer; +} + +.raw pre { + white-space: pre-wrap; + word-break: break-word; + background: #0a0f16; + border: 1px solid var(--line); + padding: 0.75rem; + border-radius: 12px; + color: #cbd5f5; + font-size: 0.78rem; +} + +.dt-dialog { + border: none; + border-radius: 16px; + padding: 0; + background: var(--card); + color: var(--text); + max-width: min(420px, 92vw); + box-shadow: var(--shadow); +} + +.dt-dialog::backdrop { + background: rgba(0, 0, 0, 0.55); +} + +.dt-dialog-inner { + padding: 1.25rem 1.35rem 1.35rem; +} + +.dt-dialog-inner h3 { + margin: 0 0 0.35rem; + font-size: 1.05rem; +} + +.dt-dialog-inner label { + display: block; + font-size: 0.82rem; + color: var(--muted); + margin: 0.75rem 0 0.35rem; +} + +.dt-dialog-inner input[type="datetime-local"] { + width: 100%; + padding: 0.55rem 0.65rem; + border-radius: 10px; + border: 1px solid var(--line); + background: #0a0f16; + color: var(--text); + font: inherit; +} + +.dt-dialog-actions { + display: flex; + gap: 0.5rem; + justify-content: flex-end; + margin-top: 1rem; + flex-wrap: wrap; +} + +.dt-dialog-actions .btn { + width: auto; +} + +.support-footer { + flex-shrink: 0; + margin-top: auto; + padding: 1.25rem clamp(1rem, 3vw, 1.5rem) 1.5rem; + border-top: 1px solid var(--line); + background: rgba(10, 15, 22, 0.92); +} + +.support-line { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem 0.5rem; + font-size: 0.92rem; + color: var(--muted); +} + +.support-title { + color: var(--text); + font-weight: 700; +} + +.support-sep { + opacity: 0.55; + user-select: none; +} + +.support-line a { + color: var(--accent); + text-decoration: none; + white-space: nowrap; +} + +.support-line a:hover { + text-decoration: underline; +} + +.support-blurb { + margin: 0.65rem 0 0; + font-size: 0.82rem; + line-height: 1.45; + color: var(--muted); + max-width: 62rem; +} diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..29c906e --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# Установка Amnezia Admin WebUI одной командой (см. README). +set -euo pipefail + +GITHUB_REPO="${GITHUB_REPO:-andrey271192/amnezia-admin}" +BRANCH="${BRANCH:-main}" +INSTALL_DIR="${INSTALL_DIR:-/opt/amnezia-admin}" +DATA_DIR="${DATA_DIR:-/opt/amnezia-admin-data}" +CONTAINER_NAME="${CONTAINER_NAME:-amnezia-admin}" +HOST_PORT="${HOST_PORT:-8080}" + +need_root() { + if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then + echo "Запустите от root: sudo bash или: curl ... | sudo bash" + exit 1 + fi +} + +need_docker() { + command -v docker >/dev/null 2>&1 || { + echo "Ошибка: нужен Docker." + exit 1 + } + docker info >/dev/null 2>&1 || { + echo "Ошибка: демон Docker не отвечает." + exit 1 + } +} + +need_root +need_docker + +REPO_SLUG="${GITHUB_REPO##*/}" +TMP="" +cleanup() { + [[ -n "${TMP}" ]] && rm -rf "${TMP}" +} +trap cleanup EXIT + +if [[ "${SKIP_DOWNLOAD:-}" != "1" ]]; then + echo "→ Клонирование релиза ${GITHUB_REPO} (${BRANCH})..." + TMP=$(mktemp -d) + curl -fsSL "https://github.com/${GITHUB_REPO}/archive/refs/heads/${BRANCH}.tar.gz" \ + | tar xz -C "${TMP}" + rm -rf "${INSTALL_DIR}" + mkdir -p "$(dirname "${INSTALL_DIR}")" + mv "${TMP}/${REPO_SLUG}-${BRANCH}" "${INSTALL_DIR}" + TMP="" +fi + +mkdir -p "${DATA_DIR}" + +BOOT_PW="" +PASS_FILE="/root/amnezia-admin.initial-password" +if [[ -f "${DATA_DIR}/password.hash" ]]; then + echo "→ В ${DATA_DIR} уже есть password.hash — контейнер поднимется с прежним паролем." +elif [[ -n "${ADMIN_PASSWORD:-}" ]]; then + BOOT_PW="${ADMIN_PASSWORD}" + echo "→ Использую ADMIN_PASSWORD из окружения." +elif [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "1" ]] || [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "true" ]]; then + echo "→ ALLOW_DEFAULT_PASSWORD=1 — см. README, пароль по умолчанию для входа." +else + BOOT_PW="$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 22 || openssl rand -hex 16)" + umask 077 + printf '%s\n' "${BOOT_PW}" >"${PASS_FILE}" + echo "→ Первый пароль записан в ${PASS_FILE}" +fi + +echo "→ Сборка образа amnezia-admin:latest ..." +docker build -t amnezia-admin:latest "${INSTALL_DIR}" + +docker rm -f "${CONTAINER_NAME}" 2>/dev/null || true + +RUN_ENV=( + -e AWG_CONTAINER="${AWG_CONTAINER:-amnezia-awg2}" +) + +if [[ -n "${BOOT_PW}" ]]; then + RUN_ENV+=( -e "ADMIN_PASSWORD=${BOOT_PW}" ) +elif [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "1" ]] || [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "true" ]]; then + RUN_ENV+=( -e "ALLOW_DEFAULT_PASSWORD=1" ) +fi + +docker run -d --name "${CONTAINER_NAME}" --restart unless-stopped \ + -p "${HOST_PORT}:3980" \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v "${DATA_DIR}:/data" \ + "${RUN_ENV[@]}" \ + amnezia-admin:latest + +IP="$(hostname -I 2>/dev/null | awk '{print $1}' || true)" +echo "" +echo "=== Готово ===" +echo "Откройте в браузере: http://${IP:-SERVER_IP}:${HOST_PORT}" +if [[ -f "${PASS_FILE}" ]]; then + echo "Первый пароль: $(cat "${PASS_FILE}")" +fi +if [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "1" ]] || [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "true" ]]; then + echo "Пароль по умолчанию (смените в панели): AmneziaAdmin!ChangeMe" +fi +echo "" +echo "Удаление: curl -fsSL https://raw.githubusercontent.com/${GITHUB_REPO}/${BRANCH}/scripts/uninstall.sh | sudo bash" diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh new file mode 100755 index 0000000..49f6224 --- /dev/null +++ b/scripts/uninstall.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Удаление контейнера и опционально данных (см. README). +set -euo pipefail + +CONTAINER_NAME="${CONTAINER_NAME:-amnezia-admin}" +IMAGE_NAME="${IMAGE_NAME:-amnezia-admin:latest}" +INSTALL_DIR="${INSTALL_DIR:-/opt/amnezia-admin}" +DATA_DIR="${DATA_DIR:-/opt/amnezia-admin-data}" + +need_root() { + if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then + echo "Запустите от root: curl ... | sudo bash" + exit 1 + fi +} + +need_root + +echo "→ Останавливаю контейнер ${CONTAINER_NAME}..." +docker rm -f "${CONTAINER_NAME}" 2>/dev/null || echo "(контейнер уже отсутствует)" + +if [[ "${REMOVE_IMAGE:-}" == "1" ]]; then + echo "→ Удаляю образ ${IMAGE_NAME}..." + docker rmi "${IMAGE_NAME}" 2>/dev/null || true +fi + +if [[ "${REMOVE_DATA:-}" == "1" ]]; then + echo "→ Удаляю данные панели ${DATA_DIR}..." + rm -rf "${DATA_DIR}" +fi + +if [[ "${REMOVE_SRC:-}" == "1" ]]; then + echo "→ Удаляю каталог исходников ${INSTALL_DIR}..." + rm -rf "${INSTALL_DIR}" +fi + +echo "Готово." +echo "Подсказка: REMOVE_DATA=1 REMOVE_SRC=1 REMOVE_IMAGE=1 curl ... | sudo bash — полная очистка." diff --git a/server.js b/server.js new file mode 100644 index 0000000..84749c0 --- /dev/null +++ b/server.js @@ -0,0 +1,604 @@ +import express from "express"; +import { spawn } from "child_process"; +import crypto from "crypto"; +import path from "path"; +import fs from "fs"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const PORT = Number(process.env.PORT || 3980); +const CONTAINER = process.env.AWG_CONTAINER || "amnezia-awg2"; +const AWG_CONF = "/opt/amnezia/awg/awg0.conf"; +const CLIENTS_JSON = "/opt/amnezia/awg/clientsTable"; + +const DATA_DIR = process.env.DATA_DIR || "/data"; +const PW_FILE = path.join(DATA_DIR, "password.hash"); +const SECRET_FILE = path.join(DATA_DIR, "session.secret"); + +const SESSION_COOKIE = "amnezia_sess"; +const SESSION_MS = 7 * 24 * 60 * 60 * 1000; + +let passwordHashStored = ""; +let sessionSecret = ""; + +function ensureDataDir() { + fs.mkdirSync(DATA_DIR, { recursive: true }); +} + +function hashPassword(password) { + const salt = crypto.randomBytes(16); + const hash = crypto.scryptSync(password, salt, 64); + return `${salt.toString("hex")}:${hash.toString("hex")}`; +} + +function verifyPassword(password, stored) { + const parts = stored.split(":"); + if (parts.length !== 2) return false; + const salt = Buffer.from(parts[0], "hex"); + const expected = Buffer.from(parts[1], "hex"); + let hash; + try { + hash = crypto.scryptSync(password, salt, 64); + } catch { + return false; + } + if (hash.length !== expected.length) return false; + return crypto.timingSafeEqual(hash, expected); +} + +function loadOrCreateSessionSecret() { + ensureDataDir(); + if (fs.existsSync(SECRET_FILE)) { + sessionSecret = fs.readFileSync(SECRET_FILE, "utf8").trim(); + if (sessionSecret.length < 32) { + throw new Error("session.secret слишком короткий — удалите файл для пересоздания"); + } + return; + } + sessionSecret = crypto.randomBytes(32).toString("hex"); + fs.writeFileSync(SECRET_FILE, `${sessionSecret}\n`, { mode: 0o600 }); +} + +function rotateSessionSecret() { + sessionSecret = crypto.randomBytes(32).toString("hex"); + fs.writeFileSync(SECRET_FILE, `${sessionSecret}\n`, { mode: 0o600 }); +} + +function bootstrapPassword() { + ensureDataDir(); + if (fs.existsSync(PW_FILE)) { + passwordHashStored = fs.readFileSync(PW_FILE, "utf8").trim(); + if (!passwordHashStored) throw new Error("password.hash пуст"); + return; + } + const bootstrap = process.env.ADMIN_PASSWORD || ""; + if (bootstrap) { + passwordHashStored = hashPassword(bootstrap); + fs.writeFileSync(PW_FILE, `${passwordHashStored}\n`, { mode: 0o600 }); + console.warn( + "Пароль сохранён в /data/password.hash. Уберите ADMIN_PASSWORD из окружения после первого старта." + ); + return; + } + const legacyToken = process.env.ADMIN_TOKEN || ""; + if (legacyToken) { + passwordHashStored = hashPassword(legacyToken); + fs.writeFileSync(PW_FILE, `${passwordHashStored}\n`, { mode: 0o600 }); + console.warn( + "Миграция: пароль взяли из ADMIN_TOKEN и сохранили в /data/password.hash. Удалите ADMIN_TOKEN из окружения." + ); + return; + } + const allowDefault = + process.env.ALLOW_DEFAULT_PASSWORD === "1" || + process.env.ALLOW_DEFAULT_PASSWORD === "true"; + const docPass = process.env.DEFAULT_ADMIN_PASSWORD || "AmneziaAdmin!ChangeMe"; + if (allowDefault) { + passwordHashStored = hashPassword(docPass); + fs.writeFileSync(PW_FILE, `${passwordHashStored}\n`, { mode: 0o600 }); + console.warn( + "Включён пароль по умолчанию из документации (README). Смените его в панели и отключите ALLOW_DEFAULT_PASSWORD." + ); + return; + } + console.error( + "Нет пароля: задайте ADMIN_PASSWORD при первом запуске, см. README, или ALLOW_DEFAULT_PASSWORD=1 только для теста." + ); + process.exit(1); +} + +function signSession(payload) { + const body = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url"); + const sig = crypto.createHmac("sha256", sessionSecret).update(body).digest("base64url"); + return `${body}.${sig}`; +} + +function readSession(token) { + if (!token || !sessionSecret) return null; + const dot = token.indexOf("."); + if (dot === -1) return null; + const body = token.slice(0, dot); + const sig = token.slice(dot + 1); + let expected; + try { + expected = crypto.createHmac("sha256", sessionSecret).update(body).digest("base64url"); + } catch { + return null; + } + try { + if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null; + } catch { + return null; + } + let payload; + try { + payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8")); + } catch { + return null; + } + if (typeof payload.exp !== "number" || payload.exp < Date.now()) return null; + return payload; +} + +function getSessionToken(req) { + const raw = req.headers.cookie || ""; + for (const part of raw.split(";")) { + const p = part.trim(); + if (p.startsWith(`${SESSION_COOKIE}=`)) { + return decodeURIComponent(p.slice(SESSION_COOKIE.length + 1)); + } + } + return null; +} + +function cookieSecureFlag() { + return process.env.COOKIE_SECURE === "1" || process.env.COOKIE_SECURE === "true"; +} + +function setSessionCookie(res, token, maxAgeSec) { + const sec = cookieSecureFlag(); + res.setHeader( + "Set-Cookie", + `${SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${maxAgeSec}; Path=/; HttpOnly; SameSite=Lax${sec ? "; Secure" : ""}` + ); +} + +function clearSessionCookie(res) { + const sec = cookieSecureFlag(); + res.setHeader( + "Set-Cookie", + `${SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${sec ? "; Secure" : ""}` + ); +} + +function requireAuth(req, res, next) { + const sess = readSession(getSessionToken(req)); + if (!sess) { + res.status(401).json({ error: "Unauthorized" }); + return; + } + next(); +} + +function execDocker(args, stdin = null) { + return new Promise((resolve, reject) => { + const child = spawn("docker", args, { stdio: ["pipe", "pipe", "pipe"] }); + let out = ""; + let err = ""; + child.stdout.on("data", (c) => (out += c)); + child.stderr.on("data", (c) => (err += c)); + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) resolve({ stdout: out, stderr: err }); + else reject(new Error(err.trim() || out.trim() || `exit ${code}`)); + }); + if (stdin != null) { + child.stdin.write(stdin); + child.stdin.end(); + } else { + child.stdin.end(); + } + }); +} + +async function dockerExec(cmd) { + const { stdout, stderr } = await execDocker([ + "exec", + CONTAINER, + "sh", + "-c", + cmd, + ]); + return stdout + stderr; +} + +async function dockerReadFile(remotePath) { + const { stdout } = await execDocker(["exec", CONTAINER, "cat", remotePath]); + return stdout; +} + +async function dockerWriteFile(remotePath, content) { + await execDocker( + [ + "exec", + "-i", + CONTAINER, + "sh", + "-c", + `cat > '${remotePath}.tmp' && mv '${remotePath}.tmp' '${remotePath}'`, + ], + content + ); +} + +function splitAwgConf(text) { + const t = text.replace(/\r\n/g, "\n"); + const parts = t.split(/(?=^\[Peer\])/m); + const head = parts[0].trimEnd(); + const peers = parts.slice(1).map(parsePeerBlock).filter((p) => p.publicKey); + return { head, peers }; +} + +function parsePeerBlock(block) { + const lineMap = (key) => { + const m = block.match(new RegExp(`^${key}\\s*=\\s*(.+)$`, "m")); + return m ? m[1].trim() : null; + }; + const publicKey = lineMap("PublicKey"); + const presharedKey = lineMap("PresharedKey"); + const allowedIPs = lineMap("AllowedIPs"); + const raw = block.trimEnd(); + return { raw, publicKey, presharedKey, allowedIPs }; +} + +function serializeAwgConf(head, peers) { + const body = peers.map((p) => p.raw.trim()).join("\n\n"); + return (body ? `${head}\n\n${body}\n` : `${head}\n`).replace(/\n+$/, "\n"); +} + +function parseClientsTable(raw) { + const data = JSON.parse(raw); + if (!Array.isArray(data)) throw new Error("clientsTable is not an array"); + return data; +} + +function stringifyClientsTable(rows) { + return `${JSON.stringify(rows, null, 4)}\n`; +} + +async function backupRemoteFiles() { + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + await dockerExec( + `cp '${AWG_CONF}' '${AWG_CONF}.bak-admin-${stamp}' 2>/dev/null || true` + ); + await dockerExec( + `cp '${CLIENTS_JSON}' '${CLIENTS_JSON}.bak-admin-${stamp}' 2>/dev/null || true` + ); +} + +async function applySyncconf() { + await dockerExec( + `wg-quick strip '${AWG_CONF}' > /tmp/wg-admin-strip.conf && awg syncconf awg0 /tmp/wg-admin-strip.conf` + ); +} + +async function loadState() { + const [confText, tableText] = await Promise.all([ + dockerReadFile(AWG_CONF), + dockerReadFile(CLIENTS_JSON), + ]); + const conf = splitAwgConf(confText); + const clients = parseClientsTable(tableText); + const peerByKey = new Map(conf.peers.map((p) => [p.publicKey, p])); + return { confText, conf, clients, peerByKey }; +} + +async function inferPskFromConf(conf) { + if (conf.peers.length) return conf.peers[0].presharedKey; + try { + const text = await dockerReadFile("/opt/amnezia/awg/wireguard_psk.key"); + return text.trim(); + } catch { + return null; + } +} + +/** ISO string; пустое значение → текущий момент */ +function normalizeDisconnectedAtOptional(raw) { + if (raw == null || raw === "") return new Date().toISOString(); + const d = new Date(raw); + if (Number.isNaN(d.getTime())) { + throw new Error("Некорректная дата disconnectedAt"); + } + return d.toISOString(); +} + +function requireDisconnectedAt(raw) { + if (raw == null || raw === "") { + throw new Error("Укажите дату отключения"); + } + const d = new Date(raw); + if (Number.isNaN(d.getTime())) { + throw new Error("Некорректная дата"); + } + return d.toISOString(); +} + +ensureDataDir(); +loadOrCreateSessionSecret(); +bootstrapPassword(); + +const app = express(); +app.use(express.json({ limit: "512kb" })); + +app.get("/health", (_req, res) => { + res.json({ ok: true }); +}); + +app.get("/api/session", (req, res) => { + if (!readSession(getSessionToken(req))) { + res.status(401).json({ ok: false }); + return; + } + res.json({ ok: true }); +}); + +app.post("/api/login", (req, res) => { + const pw = req.body?.password; + if (typeof pw !== "string" || !pw) { + res.status(400).json({ error: "password required" }); + return; + } + if (!verifyPassword(pw, passwordHashStored)) { + res.status(401).json({ error: "Неверный пароль" }); + return; + } + const token = signSession({ exp: Date.now() + SESSION_MS }); + setSessionCookie(res, token, Math.floor(SESSION_MS / 1000)); + res.json({ ok: true }); +}); + +app.post("/api/logout", (_req, res) => { + clearSessionCookie(res); + res.json({ ok: true }); +}); + +app.post("/api/change-password", requireAuth, (req, res) => { + const cur = req.body?.currentPassword; + const neu = req.body?.newPassword; + if (typeof cur !== "string" || typeof neu !== "string") { + res.status(400).json({ error: "currentPassword и newPassword обязательны" }); + return; + } + if (neu.length < 8) { + res.status(400).json({ error: "Новый пароль — не короче 8 символов" }); + return; + } + if (!verifyPassword(cur, passwordHashStored)) { + res.status(401).json({ error: "Текущий пароль неверный" }); + return; + } + passwordHashStored = hashPassword(neu); + fs.writeFileSync(PW_FILE, `${passwordHashStored}\n`, { mode: 0o600 }); + rotateSessionSecret(); + clearSessionCookie(res); + res.json({ ok: true, message: "Пароль изменён. Войдите снова." }); +}); + +app.get("/api/clients", requireAuth, async (_req, res) => { + try { + let wgShow = ""; + try { + wgShow = await dockerExec(`awg show awg0`); + } catch { + wgShow = ""; + } + const { conf, clients, peerByKey } = await loadState(); + const rows = clients.map((c) => { + const id = c.clientId; + const peer = peerByKey.get(id); + const ud = c.userData || {}; + const activeInConf = !!peer; + return { + clientId: id, + name: ud.clientName || `${id.slice(0, 10)}…`, + allowedIps: peer?.allowedIPs || ud.allowedIps || ud.preservedAllowedIPs || null, + activeInConf, + disabled: !activeInConf, + disabledAt: ud.disabledAt || null, + lastDisconnectedAt: ud.lastDisconnectedAt || null, + creationDate: ud.creationDate || null, + latestHandshake: ud.latestHandshake || null, + dataReceived: ud.dataReceived || null, + dataSent: ud.dataSent || null, + }; + }); + res.json({ + container: CONTAINER, + protocol: "AmneziaWG", + peerCount: conf.peers.length, + clients: rows, + wgShow, + }); + } catch (e) { + console.error(e); + res.status(500).json({ error: String(e.message || e) }); + } +}); + +app.post("/api/clients/disable", requireAuth, async (req, res) => { + const clientId = req.body?.clientId; + if (!clientId) return res.status(400).json({ error: "clientId required" }); + let ts; + try { + ts = normalizeDisconnectedAtOptional(req.body?.disconnectedAt); + } catch (e) { + return res.status(400).json({ error: String(e.message || e) }); + } + try { + await backupRemoteFiles(); + const { conf, clients } = await loadState(); + const peer = conf.peers.find((p) => p.publicKey === clientId); + if (!peer) { + return res.status(404).json({ error: "Peer not in config (already disabled?)" }); + } + const nextPeers = conf.peers.filter((p) => p.publicKey !== clientId); + const nextConfText = serializeAwgConf(conf.head, nextPeers); + const idx = clients.findIndex((c) => c.clientId === clientId); + if (idx === -1) { + return res.status(404).json({ error: "Client not in clientsTable" }); + } + const ud = { ...(clients[idx].userData || {}) }; + ud.disabled = true; + ud.disabledAt = ts; + ud.lastDisconnectedAt = ts; + ud.preservedPresharedKey = peer.presharedKey || ud.preservedPresharedKey; + ud.preservedAllowedIPs = peer.allowedIPs || ud.preservedAllowedIPs; + clients[idx] = { ...clients[idx], userData: ud }; + await dockerWriteFile(AWG_CONF, nextConfText); + await dockerWriteFile(CLIENTS_JSON, stringifyClientsTable(clients)); + await applySyncconf(); + res.json({ ok: true }); + } catch (e) { + console.error(e); + res.status(500).json({ error: String(e.message || e) }); + } +}); + +app.post("/api/clients/enable", requireAuth, async (req, res) => { + const clientId = req.body?.clientId; + if (!clientId) return res.status(400).json({ error: "clientId required" }); + try { + await backupRemoteFiles(); + const { conf, clients } = await loadState(); + const existing = conf.peers.find((p) => p.publicKey === clientId); + if (existing) { + return res.status(409).json({ error: "Peer already enabled" }); + } + const idx = clients.findIndex((c) => c.clientId === clientId); + if (idx === -1) { + return res.status(404).json({ error: "Client not in clientsTable" }); + } + const ud = { ...(clients[idx].userData || {}) }; + const psk = + ud.preservedPresharedKey || + conf.peers[0]?.presharedKey || + (await inferPskFromConf(conf)); + const ips = ud.preservedAllowedIPs || ud.allowedIps; + if (!psk || !ips) { + return res.status(400).json({ + error: + "Missing preserved keys — cannot enable (restore from backup or re-import in Amnezia)", + }); + } + const raw = `[Peer] +PublicKey = ${clientId} +PresharedKey = ${psk} +AllowedIPs = ${ips}`; + const peer = parsePeerBlock(`${raw}\n`); + const nextPeers = [...conf.peers, peer]; + const nextConfText = serializeAwgConf(conf.head, nextPeers); + delete ud.disabled; + delete ud.disabledAt; + delete ud.preservedPresharedKey; + delete ud.preservedAllowedIPs; + clients[idx] = { ...clients[idx], userData: ud }; + await dockerWriteFile(AWG_CONF, nextConfText); + await dockerWriteFile(CLIENTS_JSON, stringifyClientsTable(clients)); + await applySyncconf(); + res.json({ ok: true }); + } catch (e) { + console.error(e); + res.status(500).json({ error: String(e.message || e) }); + } +}); + +app.post("/api/clients/disconnect-date", requireAuth, async (req, res) => { + const clientId = req.body?.clientId; + if (!clientId) return res.status(400).json({ error: "clientId required" }); + let iso; + try { + iso = requireDisconnectedAt(req.body?.disconnectedAt); + } catch (e) { + return res.status(400).json({ error: String(e.message || e) }); + } + try { + const { conf, clients, peerByKey } = await loadState(); + const idx = clients.findIndex((c) => c.clientId === clientId); + if (idx === -1) return res.status(404).json({ error: "Client not in clientsTable" }); + const peer = peerByKey.get(clientId); + const ud = { ...(clients[idx].userData || {}) }; + ud.lastDisconnectedAt = iso; + if (!peer) { + ud.disabledAt = iso; + } + clients[idx] = { ...clients[idx], userData: ud }; + await dockerWriteFile(CLIENTS_JSON, stringifyClientsTable(clients)); + res.json({ ok: true }); + } catch (e) { + console.error(e); + res.status(500).json({ error: String(e.message || e) }); + } +}); + +app.post("/api/clients/rename", requireAuth, async (req, res) => { + const clientId = req.body?.clientId; + const rawName = req.body?.name ?? req.body?.clientName; + if (!clientId) return res.status(400).json({ error: "clientId required" }); + if (typeof rawName !== "string") { + return res.status(400).json({ error: "name required" }); + } + const name = rawName.trim().replace(/\s+/g, " "); + if (!name) return res.status(400).json({ error: "Имя не может быть пустым" }); + if (name.length > 200) { + return res.status(400).json({ error: "Имя не длиннее 200 символов" }); + } + try { + const { clients } = await loadState(); + const idx = clients.findIndex((c) => c.clientId === clientId); + if (idx === -1) return res.status(404).json({ error: "Client not in clientsTable" }); + const ud = { ...(clients[idx].userData || {}), clientName: name }; + clients[idx] = { ...clients[idx], userData: ud }; + await dockerWriteFile(CLIENTS_JSON, stringifyClientsTable(clients)); + res.json({ ok: true }); + } catch (e) { + console.error(e); + res.status(500).json({ error: String(e.message || e) }); + } +}); + +app.post("/api/clients/delete", requireAuth, async (req, res) => { + const clientId = req.body?.clientId; + if (!clientId) return res.status(400).json({ error: "clientId required" }); + try { + await backupRemoteFiles(); + const { conf, clients } = await loadState(); + const nextPeers = conf.peers.filter((p) => p.publicKey !== clientId); + const nextClients = clients.filter((c) => c.clientId !== clientId); + if (nextClients.length === clients.length) { + return res.status(404).json({ error: "Client not in clientsTable" }); + } + const nextConfText = serializeAwgConf(conf.head, nextPeers); + await dockerWriteFile(AWG_CONF, nextConfText); + await dockerWriteFile(CLIENTS_JSON, stringifyClientsTable(nextClients)); + await applySyncconf(); + res.json({ ok: true }); + } catch (e) { + console.error(e); + res.status(500).json({ error: String(e.message || e) }); + } +}); + +const pub = path.join(__dirname, "public"); +if (fs.existsSync(pub)) { + app.use(express.static(pub)); +} + +app.use((_req, res) => { + res.status(404).send("Not found"); +}); + +app.listen(PORT, "0.0.0.0", () => { + console.log(`amnezia-admin on :${PORT} → docker:${CONTAINER}, data:${DATA_DIR}`); +});