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 <cursoragent@cursor.com>
This commit is contained in:
Андрей Бобырев
2026-05-14 15:40:20 +03:00
commit ede0c66415
13 changed files with 1952 additions and 0 deletions

2
.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
node_modules
npm-debug.log

8
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,8 @@
# Способы поддержки автора (GitHub показывает кнопку Sponsor на базе github:).
# Прямые ссылки см. README → «Поддержка проекта» и футер веб-панели.
github: andrey271192
custom:
# Boosty (пример из интерфейса проекта; замените на свой профиль при необходимости)
- https://boosty.to/lot_andrey

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
npm-debug.log*
.DS_Store
*.swp
.env
.env.*

20
Dockerfile Normal file
View File

@@ -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"]

21
LICENSE Normal file
View File

@@ -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.

131
README.md Normal file
View File

@@ -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).

18
package.json Normal file
View File

@@ -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"
}
}

381
public/app.js Normal file
View File

@@ -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 = `<span class="ip">${escapeHtml(c.allowedIps || "—")}</span>`;
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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();

120
public/index.html Normal file
View File

@@ -0,0 +1,120 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AmneziaWG — клиенты</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;600;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/styles.css">
</head>
<body class="page">
<div id="login-gate" class="gate hidden" aria-hidden="true">
<div class="gate-card">
<p class="eyebrow">Панель сервера</p>
<h1>Вход</h1>
<p class="sub">Введите пароль администратора панели.</p>
<form id="login-form">
<label for="login-password">Пароль</label>
<input id="login-password" type="password" autocomplete="current-password" required>
<button type="submit" class="btn primary full">Войти</button>
<p id="login-error" class="status err" role="alert"></p>
</form>
</div>
</div>
<div id="app-root" class="shell hidden">
<header class="top">
<div>
<p class="eyebrow">Панель сервера</p>
<h1>Пользователи AmneziaWG</h1>
<p class="sub">Дату отключения вы задаёте сами: при нажатии «Выключить» или кнопкой «Задать дату» в колонке «Последнее отключение». Время берётся как локальное на вашем компьютере.</p>
</div>
<div class="token-box">
<div class="session-actions">
<button type="button" id="logout" class="btn ghost full">Выйти</button>
</div>
<details class="pw-change">
<summary>Сменить пароль</summary>
<form id="pw-form">
<label for="pw-current">Текущий пароль</label>
<input id="pw-current" type="password" autocomplete="current-password" required>
<label for="pw-new">Новый пароль</label>
<input id="pw-new" type="password" autocomplete="new-password" required minlength="8">
<label for="pw-new2">Повтор нового пароля</label>
<input id="pw-new2" type="password" autocomplete="new-password" required minlength="8">
<button type="submit" class="btn primary full">Сохранить новый пароль</button>
<p id="pw-msg" class="status" role="status"></p>
</form>
</details>
</div>
</header>
<section class="toolbar">
<div class="pill"><span class="dot ok"></span><span id="proto-label">Протокол: AmneziaWG</span></div>
<button type="button" id="refresh" class="btn primary">Обновить</button>
</section>
<p id="status" class="status" role="status"></p>
<section class="panel">
<div class="panel-head">
<h2>Пользователи</h2>
<span class="muted" id="peer-count"></span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Имя</th>
<th>IP</th>
<th>Статус</th>
<th>Последнее отключение</th>
<th></th>
</tr>
</thead>
<tbody id="rows"></tbody>
</table>
</div>
</section>
<details class="raw">
<summary>Вывод awg show (отладка)</summary>
<pre id="wg-show"></pre>
</details>
</div>
<footer class="support-footer">
<div class="support-line">
<strong class="support-title">Amnezia Admin WebUI</strong>
<span class="support-sep" aria-hidden="true">·</span>
<a href="https://github.com/andrey271192/amnezia-admin" target="_blank" rel="noopener noreferrer">⭐ GitHub</a>
<span class="support-sep" aria-hidden="true">·</span>
<a href="https://boosty.to/lot_andrey" target="_blank" rel="noopener noreferrer">💖 Boosty</a>
<span class="support-sep" aria-hidden="true">·</span>
<a href="https://github.com/andrey271192/amnezia-admin/blob/main/README.md#support-links" target="_blank" rel="noopener noreferrer">💳 Ozon&nbsp;СБП</a>
<span class="support-sep" aria-hidden="true">·</span>
<a href="https://t.me/lot_andrey" target="_blank" rel="noopener noreferrer">✉️ Telegram&nbsp;@lot_andrey</a>
</div>
<p class="support-blurb">
Поддержать проект — поставь звезду на GitHub или донат. Связаться с автором — Telegram.
</p>
</footer>
<dialog id="disconnect-dt-dialog" class="dt-dialog">
<div class="dt-dialog-inner">
<h3 id="dt-dialog-title">Дата отключения</h3>
<p class="muted" id="dt-dialog-client"></p>
<label for="dt-dialog-input">Дата и время</label>
<input type="datetime-local" id="dt-dialog-input" step="60">
<div class="dt-dialog-actions">
<button type="button" class="btn ghost" id="dt-dialog-cancel">Отмена</button>
<button type="button" class="btn primary" id="dt-dialog-ok">Подтвердить</button>
</div>
</div>
</dialog>
<script src="/app.js" type="module"></script>
</body>
</html>

501
public/styles.css Normal file
View File

@@ -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;
}

102
scripts/install.sh Executable file
View File

@@ -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"

38
scripts/uninstall.sh Executable file
View File

@@ -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 — полная очистка."

604
server.js Normal file
View File

@@ -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}`);
});