mirror of
https://github.com/andrey271192/amnezia_web-PRO.git
synced 2026-09-20 14:42:00 +00:00
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:
2
.dockerignore
Normal file
2
.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
8
.github/FUNDING.yml
vendored
Normal file
8
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Способы поддержки автора (GitHub показывает кнопку Sponsor на базе github:).
|
||||||
|
# Прямые ссылки см. README → «Поддержка проекта» и футер веб-панели.
|
||||||
|
|
||||||
|
github: andrey271192
|
||||||
|
|
||||||
|
custom:
|
||||||
|
# Boosty (пример из интерфейса проекта; замените на свой профиль при необходимости)
|
||||||
|
- https://boosty.to/lot_andrey
|
||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
.DS_Store
|
||||||
|
*.swp
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
20
Dockerfile
Normal file
20
Dockerfile
Normal 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
21
LICENSE
Normal 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
131
README.md
Normal 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
18
package.json
Normal 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
381
public/app.js
Normal 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, "&")
|
||||||
|
.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();
|
||||||
120
public/index.html
Normal file
120
public/index.html
Normal 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 СБП</a>
|
||||||
|
<span class="support-sep" aria-hidden="true">·</span>
|
||||||
|
<a href="https://t.me/lot_andrey" target="_blank" rel="noopener noreferrer">✉️ Telegram @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
501
public/styles.css
Normal 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
102
scripts/install.sh
Executable 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
38
scripts/uninstall.sh
Executable 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
604
server.js
Normal 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}`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user