mirror of
https://github.com/andrey271192/amnezia_web-PRO.git
synced 2026-09-20 14:42:00 +00:00
feat: AWG profile switcher; scheduled tunnel disconnect
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
13
README.md
13
README.md
@@ -36,6 +36,8 @@ cd /opt/amnezia-admin && chmod +x scripts/install.sh && sudo SKIP_DOWNLOAD=1 bas
|
|||||||
| `DATA_DIR` | `/opt/amnezia-admin-data` | Том с `password.hash` и сессией |
|
| `DATA_DIR` | `/opt/amnezia-admin-data` | Том с `password.hash` и сессией |
|
||||||
| `HOST_PORT` | `8080` | Порт HTTP панели на хосте |
|
| `HOST_PORT` | `8080` | Порт HTTP панели на хосте |
|
||||||
| `AWG_CONTAINER` | `amnezia-awg2` | Имя контейнера Amnezia WG |
|
| `AWG_CONTAINER` | `amnezia-awg2` | Имя контейнера Amnezia WG |
|
||||||
|
| `AWG_PROFILES` | _(нет)_ | JSON-массив профилей: несколько контейнеров/путей (см. ниже). Если задан — переключатель «Инстанс» в вебе |
|
||||||
|
| `SCHEDULE_DISCONNECT_MS` | `60000` | Как часто планировщик проверяет отложенное отключение из туннеля (мс) |
|
||||||
| `ADMIN_PASSWORD` | _(генерируется)_ | Первый пароль вместо файла |
|
| `ADMIN_PASSWORD` | _(генерируется)_ | Первый пароль вместо файла |
|
||||||
| `SKIP_DOWNLOAD` | `0` | `1` — не качать GitHub, собрать из `INSTALL_DIR` |
|
| `SKIP_DOWNLOAD` | `0` | `1` — не качать GitHub, собрать из `INSTALL_DIR` |
|
||||||
| `ALLOW_DEFAULT_PASSWORD` | `0` | `1` — см. раздел «Пароль» ниже |
|
| `ALLOW_DEFAULT_PASSWORD` | `0` | `1` — см. раздел «Пароль» ниже |
|
||||||
@@ -44,6 +46,17 @@ cd /opt/amnezia-admin && chmod +x scripts/install.sh && sudo SKIP_DOWNLOAD=1 bas
|
|||||||
| `LANDING_CONTAINER` | `amnezia-web-landing` | Имя контейнера лендинга |
|
| `LANDING_CONTAINER` | `amnezia-web-landing` | Имя контейнера лендинга |
|
||||||
| `NO_CACHE` | `0` | `1` — `docker build --no-cache` при проблемах с обновлением образа |
|
| `NO_CACHE` | `0` | `1` — `docker build --no-cache` при проблемах с обновлением образа |
|
||||||
|
|
||||||
|
#### Несколько инстансов (AmneziaWG + Legacy и т.д.)
|
||||||
|
|
||||||
|
Пути и имена контейнеров на сервере могут отличаться — проверьте внутри контейнера (`docker exec … ls /opt/amnezia`). Пример **двух** профилей при запуске установщика (одна строка JSON в кавычках):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AWG_PROFILES='[{"id":"awg","label":"AmneziaWG","container":"amnezia-awg2","confPath":"/opt/amnezia/awg/awg0.conf","clientsPath":"/opt/amnezia/awg/clientsTable","iface":"awg0","wgBinary":"awg"},{"id":"legacy","label":"AmneziaWG Legacy","container":"amnezia-wg0","confPath":"/opt/amnezia/wireguard/wg0.conf","clientsPath":"/opt/amnezia/wireguard/clientsTable","iface":"wg0","wgBinary":"wg"}]' \
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/andrey271192/Amnezia_web/main/scripts/install.sh | sudo -E bash
|
||||||
|
```
|
||||||
|
|
||||||
|
Для **Legacy** часто используется обычный `wg`, для новой AmneziaWG — `awg`; подставьте свои `container`, `confPath`, `clientsPath`, `iface`, `pskPath` при необходимости.
|
||||||
|
|
||||||
После установки: **админ-панель** `http://IP:8080` (или ваш `HOST_PORT`), **страница с поддержкой проекта** `http://IP/` на порту лендинга (по умолчанию **80**). Кнопка на лендинге ведёт на админку с тем же `HOST_PORT`.
|
После установки: **админ-панель** `http://IP:8080` (или ваш `HOST_PORT`), **страница с поддержкой проекта** `http://IP/` на порту лендинга (по умолчанию **80**). Кнопка на лендинге ведёт на админку с тем же `HOST_PORT`.
|
||||||
|
|
||||||
Футер с ссылками (**Amnezia Admin WebUI**, Boosty, Ozon СБП, Telegram) в админке находится **внизу страницы** — прокрутите ниже таблицы.
|
Футер с ссылками (**Amnezia Admin WebUI**, Boosty, Ozon СБП, Telegram) в админке находится **внизу страницы** — прокрутите ниже таблицы.
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ const statusEl = document.querySelector("#status");
|
|||||||
const peerCountEl = document.querySelector("#peer-count");
|
const peerCountEl = document.querySelector("#peer-count");
|
||||||
const wgShowEl = document.querySelector("#wg-show");
|
const wgShowEl = document.querySelector("#wg-show");
|
||||||
|
|
||||||
|
const protoSwitch = document.querySelector("#proto-switch");
|
||||||
|
const protoSelect = document.querySelector("#proto-select");
|
||||||
|
const protoLabel = document.querySelector("#proto-label");
|
||||||
|
|
||||||
const pwForm = document.querySelector("#pw-form");
|
const pwForm = document.querySelector("#pw-form");
|
||||||
const pwCurrent = document.querySelector("#pw-current");
|
const pwCurrent = document.querySelector("#pw-current");
|
||||||
const pwNew = document.querySelector("#pw-new");
|
const pwNew = document.querySelector("#pw-new");
|
||||||
@@ -28,7 +32,7 @@ const dtCancel = document.querySelector("#dt-dialog-cancel");
|
|||||||
const dtOk = document.querySelector("#dt-dialog-ok");
|
const dtOk = document.querySelector("#dt-dialog-ok");
|
||||||
const dtExtra = document.querySelector("#dt-dialog-extra");
|
const dtExtra = document.querySelector("#dt-dialog-extra");
|
||||||
const dtHint = document.querySelector("#dt-dialog-hint");
|
const dtHint = document.querySelector("#dt-dialog-hint");
|
||||||
const dtAlsoDisable = document.querySelector("#dt-dialog-also-disable");
|
const dtScheduleTunnel = document.querySelector("#dt-dialog-schedule-tunnel");
|
||||||
|
|
||||||
let dtMode = "disable";
|
let dtMode = "disable";
|
||||||
/** @type {Record<string, unknown> | null} */
|
/** @type {Record<string, unknown> | null} */
|
||||||
@@ -61,7 +65,7 @@ function openDisableDialog(c) {
|
|||||||
dtOk.textContent = "Выключить";
|
dtOk.textContent = "Выключить";
|
||||||
dtInput.value = isoToDatetimeLocal(new Date().toISOString());
|
dtInput.value = isoToDatetimeLocal(new Date().toISOString());
|
||||||
dtExtra.classList.add("hidden");
|
dtExtra.classList.add("hidden");
|
||||||
dtAlsoDisable.checked = false;
|
dtScheduleTunnel.checked = false;
|
||||||
dtDialog.showModal();
|
dtDialog.showModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,16 +76,19 @@ function openEditDisconnectDialog(c) {
|
|||||||
dtClientEl.textContent = c.name;
|
dtClientEl.textContent = c.name;
|
||||||
dtOk.textContent = "Сохранить";
|
dtOk.textContent = "Сохранить";
|
||||||
const iso =
|
const iso =
|
||||||
c.lastDisconnectedAt || (!c.activeInConf && c.disabledAt) || new Date().toISOString();
|
(c.activeInConf && c.scheduledTunnelDisconnectAt) ||
|
||||||
|
c.lastDisconnectedAt ||
|
||||||
|
(!c.activeInConf && c.disabledAt) ||
|
||||||
|
new Date().toISOString();
|
||||||
dtInput.value = isoToDatetimeLocal(iso);
|
dtInput.value = isoToDatetimeLocal(iso);
|
||||||
if (c.activeInConf) {
|
if (c.activeInConf) {
|
||||||
dtExtra.classList.remove("hidden");
|
dtExtra.classList.remove("hidden");
|
||||||
dtHint.textContent =
|
dtHint.textContent =
|
||||||
"Без галочки меняется только дата в таблице — в туннеле клиент остаётся. Чтобы реально отключить ключ, включите «Выключить из туннеля».";
|
"Без галочки — только запись даты в таблице, клиент остаётся в туннеле. С галочкой ключ будет убран из туннеля автоматически в выбранный момент (проверка на сервере каждые ~60 с).";
|
||||||
dtAlsoDisable.checked = false;
|
dtScheduleTunnel.checked = Boolean(c.scheduledTunnelDisconnectAt);
|
||||||
} else {
|
} else {
|
||||||
dtExtra.classList.add("hidden");
|
dtExtra.classList.add("hidden");
|
||||||
dtAlsoDisable.checked = false;
|
dtScheduleTunnel.checked = false;
|
||||||
}
|
}
|
||||||
dtDialog.showModal();
|
dtDialog.showModal();
|
||||||
}
|
}
|
||||||
@@ -108,20 +115,17 @@ dtOk.addEventListener("click", async () => {
|
|||||||
body: JSON.stringify({ clientId: dtClient.clientId, disconnectedAt: iso }),
|
body: JSON.stringify({ clientId: dtClient.clientId, disconnectedAt: iso }),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const alsoTunnel = Boolean(dtAlsoDisable.checked && dtClient.activeInConf);
|
const scheduleTunnel = Boolean(dtScheduleTunnel.checked && dtClient.activeInConf);
|
||||||
setStatus(alsoTunnel ? "Выключаю из туннеля…" : "Сохраняю дату…", false);
|
setStatus(scheduleTunnel ? "Сохраняю расписание отключения…" : "Сохраняю дату…", false);
|
||||||
if (alsoTunnel) {
|
|
||||||
await api("/api/clients/disable", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ clientId: dtClient.clientId, disconnectedAt: iso }),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await api("/api/clients/disconnect-date", {
|
await api("/api/clients/disconnect-date", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ clientId: dtClient.clientId, disconnectedAt: iso }),
|
body: JSON.stringify({
|
||||||
|
clientId: dtClient.clientId,
|
||||||
|
disconnectedAt: iso,
|
||||||
|
scheduleTunnelDisconnect: scheduleTunnel,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
dtDialog.close();
|
dtDialog.close();
|
||||||
dtClient = null;
|
dtClient = null;
|
||||||
setStatus("Готово.", false);
|
setStatus("Готово.", false);
|
||||||
@@ -187,6 +191,28 @@ async function checkSession() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadProtocols() {
|
||||||
|
try {
|
||||||
|
const data = await api("/api/protocols");
|
||||||
|
protoLabel.textContent = `Протокол: ${data.currentLabel || "AmneziaWG"}`;
|
||||||
|
if (!data.profiles || data.profiles.length < 2) {
|
||||||
|
protoSwitch.classList.add("hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
protoSwitch.classList.remove("hidden");
|
||||||
|
protoSelect.innerHTML = "";
|
||||||
|
for (const p of data.profiles) {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = p.id;
|
||||||
|
opt.textContent = `${p.label} (${p.container})`;
|
||||||
|
if (p.id === data.currentId) opt.selected = true;
|
||||||
|
protoSelect.appendChild(opt);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
protoSwitch.classList.add("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loginForm.addEventListener("submit", async (ev) => {
|
loginForm.addEventListener("submit", async (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
loginError.textContent = "";
|
loginError.textContent = "";
|
||||||
@@ -197,6 +223,7 @@ loginForm.addEventListener("submit", async (ev) => {
|
|||||||
});
|
});
|
||||||
loginPassword.value = "";
|
loginPassword.value = "";
|
||||||
showApp();
|
showApp();
|
||||||
|
await loadProtocols();
|
||||||
await loadClients();
|
await loadClients();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
loginError.textContent = String(e.message || e);
|
loginError.textContent = String(e.message || e);
|
||||||
@@ -217,6 +244,22 @@ refreshBtn.addEventListener("click", () => {
|
|||||||
loadClients();
|
loadClients();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
protoSelect.addEventListener("change", async () => {
|
||||||
|
try {
|
||||||
|
setStatus("Смена инстанса…", false);
|
||||||
|
await api("/api/protocol", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ profileId: protoSelect.value }),
|
||||||
|
});
|
||||||
|
await loadProtocols();
|
||||||
|
await loadClients();
|
||||||
|
setStatus("", false);
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(String(e.message || e), true);
|
||||||
|
await loadProtocols();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
clockSyncBtn.addEventListener("click", () => {
|
clockSyncBtn.addEventListener("click", () => {
|
||||||
void refreshServerClock();
|
void refreshServerClock();
|
||||||
});
|
});
|
||||||
@@ -318,6 +361,12 @@ const dtRu = new Intl.DateTimeFormat("ru-RU", {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function formatLastDisconnect(c) {
|
function formatLastDisconnect(c) {
|
||||||
|
if (c.scheduledTunnelDisconnectAt && c.activeInConf) {
|
||||||
|
const d = new Date(String(c.scheduledTunnelDisconnectAt));
|
||||||
|
if (!Number.isNaN(d.getTime())) {
|
||||||
|
return `${dtRu.format(d)} · авто`;
|
||||||
|
}
|
||||||
|
}
|
||||||
const iso =
|
const iso =
|
||||||
c.lastDisconnectedAt ||
|
c.lastDisconnectedAt ||
|
||||||
(!c.activeInConf && c.disabledAt) ||
|
(!c.activeInConf && c.disabledAt) ||
|
||||||
@@ -470,7 +519,8 @@ async function loadClients() {
|
|||||||
try {
|
try {
|
||||||
setStatus("Загрузка…", false);
|
setStatus("Загрузка…", false);
|
||||||
const data = await api("/api/clients");
|
const data = await api("/api/clients");
|
||||||
peerCountEl.textContent = `${data.clients.length} в таблице · ${data.peerCount} peer в awg0.conf`;
|
const pref = data.profileLabel ? `${data.profileLabel} · ` : "";
|
||||||
|
peerCountEl.textContent = `${pref}${data.clients.length} в таблице · ${data.peerCount} peer`;
|
||||||
wgShowEl.textContent = data.wgShow || "";
|
wgShowEl.textContent = data.wgShow || "";
|
||||||
renderRows(data.clients);
|
renderRows(data.clients);
|
||||||
setStatus("", false);
|
setStatus("", false);
|
||||||
@@ -494,6 +544,7 @@ async function boot() {
|
|||||||
const ok = await checkSession();
|
const ok = await checkSession();
|
||||||
if (ok) {
|
if (ok) {
|
||||||
showApp();
|
showApp();
|
||||||
|
await loadProtocols();
|
||||||
await loadClients();
|
await loadClients();
|
||||||
} else {
|
} else {
|
||||||
showLogin();
|
showLogin();
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Панель сервера</p>
|
<p class="eyebrow">Панель сервера</p>
|
||||||
<h1>Пользователи AmneziaWG</h1>
|
<h1>Пользователи AmneziaWG</h1>
|
||||||
<p class="sub">Дату отключения вы задаёте сами: при «Выключить» или в диалоге «Задать дату» (для активных клиентов там можно отметить выключение из туннеля). Время — локальное на вашем компьютере.</p>
|
<p class="sub">Дату отключения задаёте сами (локальное время браузера). Кнопка «Выключить» убирает клиента из туннеля сразу. «Задать дату» без галочки — только запись в таблице; с галочкой — клиент остаётся в туннеле до наступления времени. Несколько инстансов AmneziaWG (Legacy и др.) — в переменной AWG_PROFILES и списке «Инстанс».</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="token-box">
|
<div class="token-box">
|
||||||
<div class="session-actions">
|
<div class="session-actions">
|
||||||
@@ -52,7 +52,11 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="toolbar">
|
<section class="toolbar">
|
||||||
<div class="pill"><span class="dot ok"></span><span id="proto-label">Протокол: AmneziaWG</span></div>
|
<div class="pill proto-pill"><span class="dot ok"></span><span id="proto-label">Протокол: AmneziaWG</span></div>
|
||||||
|
<div id="proto-switch" class="proto-switch hidden">
|
||||||
|
<label for="proto-select" class="proto-switch-label muted">Инстанс</label>
|
||||||
|
<select id="proto-select" class="proto-select" aria-label="Инстанс AmneziaWG"></select>
|
||||||
|
</div>
|
||||||
<div class="clock-strip" role="status" aria-live="polite">
|
<div class="clock-strip" role="status" aria-live="polite">
|
||||||
<div class="clock-row">
|
<div class="clock-row">
|
||||||
<span class="muted clock-kind">Сервер</span>
|
<span class="muted clock-kind">Сервер</span>
|
||||||
@@ -127,8 +131,8 @@
|
|||||||
<div id="dt-dialog-extra" class="dt-dialog-extra hidden">
|
<div id="dt-dialog-extra" class="dt-dialog-extra hidden">
|
||||||
<p class="dt-dialog-hint" id="dt-dialog-hint"></p>
|
<p class="dt-dialog-hint" id="dt-dialog-hint"></p>
|
||||||
<label class="dt-checkbox-label">
|
<label class="dt-checkbox-label">
|
||||||
<input type="checkbox" id="dt-dialog-also-disable">
|
<input type="checkbox" id="dt-dialog-schedule-tunnel">
|
||||||
Выключить клиента из туннеля AmneziaWG (убрать из awg0.conf)
|
Убрать из туннеля в указанное время (до этого клиент остаётся в туннеле)
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="dt-dialog-actions">
|
<div class="dt-dialog-actions">
|
||||||
|
|||||||
@@ -201,10 +201,32 @@ h1 {
|
|||||||
margin-top: 1.25rem;
|
margin-top: 1.25rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.proto-switch {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
min-width: 12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proto-switch-label {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proto-select {
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #0a0f16;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
max-width: 22rem;
|
||||||
|
}
|
||||||
|
|
||||||
.clock-strip {
|
.clock-strip {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -97,6 +97,10 @@ RUN_ENV=(
|
|||||||
-e AWG_CONTAINER="${AWG_CONTAINER:-amnezia-awg2}"
|
-e AWG_CONTAINER="${AWG_CONTAINER:-amnezia-awg2}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if [[ -n "${AWG_PROFILES:-}" ]]; then
|
||||||
|
RUN_ENV+=( -e "AWG_PROFILES=${AWG_PROFILES}" )
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ -n "${BOOT_PW}" ]]; then
|
if [[ -n "${BOOT_PW}" ]]; then
|
||||||
RUN_ENV+=( -e "ADMIN_PASSWORD=${BOOT_PW}" )
|
RUN_ENV+=( -e "ADMIN_PASSWORD=${BOOT_PW}" )
|
||||||
elif [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "1" ]] || [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "true" ]]; then
|
elif [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "1" ]] || [[ "${ALLOW_DEFAULT_PASSWORD:-}" == "true" ]]; then
|
||||||
|
|||||||
354
server.js
354
server.js
@@ -8,9 +8,50 @@ import { fileURLToPath } from "url";
|
|||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT || 3980);
|
const PORT = Number(process.env.PORT || 3980);
|
||||||
const CONTAINER = process.env.AWG_CONTAINER || "amnezia-awg2";
|
const PROFILE_COOKIE = "amnezia_prof";
|
||||||
const AWG_CONF = "/opt/amnezia/awg/awg0.conf";
|
const SCHEDULER_MS = Number(process.env.SCHEDULE_DISCONNECT_MS || 60_000);
|
||||||
const CLIENTS_JSON = "/opt/amnezia/awg/clientsTable";
|
|
||||||
|
function parseProfilesFromEnv() {
|
||||||
|
const raw = process.env.AWG_PROFILES?.trim();
|
||||||
|
const fallback = () => [
|
||||||
|
{
|
||||||
|
id: "awg",
|
||||||
|
label: process.env.AWG_PROFILE_LABEL || "AmneziaWG",
|
||||||
|
container: process.env.AWG_CONTAINER || "amnezia-awg2",
|
||||||
|
confPath: process.env.AWG_CONF_PATH || "/opt/amnezia/awg/awg0.conf",
|
||||||
|
clientsPath: process.env.AWG_CLIENTS_PATH || "/opt/amnezia/awg/clientsTable",
|
||||||
|
iface: process.env.AWG_IFACE || "awg0",
|
||||||
|
wgBinary: process.env.AWG_BINARY || "awg",
|
||||||
|
pskPath: process.env.AWG_PSK_PATH || "/opt/amnezia/awg/wireguard_psk.key",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
if (!raw) return fallback();
|
||||||
|
try {
|
||||||
|
const arr = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(arr) || arr.length === 0) return fallback();
|
||||||
|
return arr
|
||||||
|
.map((row, i) => ({
|
||||||
|
id: String(row.id ?? `p${i}`),
|
||||||
|
label: String(row.label ?? row.id ?? `Профиль ${i + 1}`),
|
||||||
|
container: String(row.container ?? ""),
|
||||||
|
confPath: String(row.confPath ?? row.conf ?? "/opt/amnezia/awg/awg0.conf"),
|
||||||
|
clientsPath: String(row.clientsPath ?? row.clients ?? "/opt/amnezia/awg/clientsTable"),
|
||||||
|
iface: String(row.iface ?? row.IFACE ?? "awg0"),
|
||||||
|
wgBinary: String(row.wgBinary ?? row.binary ?? "awg"),
|
||||||
|
pskPath: String(row.pskPath ?? row.psk ?? "/opt/amnezia/awg/wireguard_psk.key"),
|
||||||
|
}))
|
||||||
|
.filter((p) => p.container);
|
||||||
|
} catch {
|
||||||
|
console.warn("AWG_PROFILES: невалидный JSON, используется профиль по умолчанию.");
|
||||||
|
return fallback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROFILES = parseProfilesFromEnv();
|
||||||
|
if (!PROFILES.length) {
|
||||||
|
console.error("Нет ни одного профиля AWG: укажите container в AWG_PROFILES или переменные по умолчанию.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
const DATA_DIR = process.env.DATA_DIR || "/data";
|
const DATA_DIR = process.env.DATA_DIR || "/data";
|
||||||
const PW_FILE = path.join(DATA_DIR, "password.hash");
|
const PW_FILE = path.join(DATA_DIR, "password.hash");
|
||||||
@@ -152,6 +193,20 @@ function getSessionToken(req) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getProfileCookie(req) {
|
||||||
|
const raw = req.headers.cookie || "";
|
||||||
|
if (!raw) return null;
|
||||||
|
for (const part of raw.split(";")) {
|
||||||
|
const s = part.trim();
|
||||||
|
const eq = s.indexOf("=");
|
||||||
|
if (eq === -1) continue;
|
||||||
|
const k = decodeURIComponent(s.slice(0, eq).trim());
|
||||||
|
if (k !== PROFILE_COOKIE) continue;
|
||||||
|
return decodeURIComponent(s.slice(eq + 1).trim());
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function cookieSecureFlag() {
|
function cookieSecureFlag() {
|
||||||
return process.env.COOKIE_SECURE === "1" || process.env.COOKIE_SECURE === "true";
|
return process.env.COOKIE_SECURE === "1" || process.env.COOKIE_SECURE === "true";
|
||||||
}
|
}
|
||||||
@@ -172,6 +227,14 @@ function clearSessionCookie(res) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setProfileCookie(res, profileId) {
|
||||||
|
const sec = cookieSecureFlag();
|
||||||
|
res.setHeader(
|
||||||
|
"Set-Cookie",
|
||||||
|
`${PROFILE_COOKIE}=${encodeURIComponent(profileId)}; Max-Age=${31536000}; Path=/; SameSite=Lax${sec ? "; Secure" : ""}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function requireAuth(req, res, next) {
|
function requireAuth(req, res, next) {
|
||||||
const sess = readSession(getSessionToken(req));
|
const sess = readSession(getSessionToken(req));
|
||||||
if (!sess) {
|
if (!sess) {
|
||||||
@@ -202,34 +265,91 @@ function execDocker(args, stdin = null) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function dockerExec(cmd) {
|
function createRuntime(profile) {
|
||||||
const { stdout, stderr } = await execDocker([
|
const container = profile.container;
|
||||||
"exec",
|
const confPath = profile.confPath;
|
||||||
CONTAINER,
|
const clientsPath = profile.clientsPath;
|
||||||
"sh",
|
const iface = profile.iface;
|
||||||
"-c",
|
const wgBinary = profile.wgBinary;
|
||||||
cmd,
|
const pskPath = profile.pskPath;
|
||||||
]);
|
|
||||||
|
async function dockerExec(cmd) {
|
||||||
|
const { stdout, stderr } = await execDocker(["exec", container, "sh", "-c", cmd]);
|
||||||
return stdout + stderr;
|
return stdout + stderr;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function dockerReadFile(remotePath) {
|
async function dockerReadFile(remotePath) {
|
||||||
const { stdout } = await execDocker(["exec", CONTAINER, "cat", remotePath]);
|
const { stdout } = await execDocker(["exec", container, "cat", remotePath]);
|
||||||
return stdout;
|
return stdout;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function dockerWriteFile(remotePath, content) {
|
async function dockerWriteFile(remotePath, content) {
|
||||||
await execDocker(
|
await execDocker(
|
||||||
[
|
[
|
||||||
"exec",
|
"exec",
|
||||||
"-i",
|
"-i",
|
||||||
CONTAINER,
|
container,
|
||||||
"sh",
|
"sh",
|
||||||
"-c",
|
"-c",
|
||||||
`cat > '${remotePath}.tmp' && mv '${remotePath}.tmp' '${remotePath}'`,
|
`cat > '${remotePath}.tmp' && mv '${remotePath}.tmp' '${remotePath}'`,
|
||||||
],
|
],
|
||||||
content
|
content
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function backupRemoteFiles() {
|
||||||
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
|
await dockerExec(`cp '${confPath}' '${confPath}.bak-admin-${stamp}' 2>/dev/null || true`);
|
||||||
|
await dockerExec(
|
||||||
|
`cp '${clientsPath}' '${clientsPath}.bak-admin-${stamp}' 2>/dev/null || true`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applySyncconf() {
|
||||||
|
await dockerExec(
|
||||||
|
`wg-quick strip '${confPath}' > /tmp/wg-admin-strip.conf && ${wgBinary} syncconf ${iface} /tmp/wg-admin-strip.conf`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadState() {
|
||||||
|
const [confText, tableText] = await Promise.all([
|
||||||
|
dockerReadFile(confPath),
|
||||||
|
dockerReadFile(clientsPath),
|
||||||
|
]);
|
||||||
|
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(pskPath);
|
||||||
|
return text.trim();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
profile,
|
||||||
|
dockerExec,
|
||||||
|
dockerReadFile,
|
||||||
|
dockerWriteFile,
|
||||||
|
backupRemoteFiles,
|
||||||
|
applySyncconf,
|
||||||
|
loadState,
|
||||||
|
inferPskFromConf,
|
||||||
|
confPath,
|
||||||
|
clientsPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeForRequest(req) {
|
||||||
|
const wanted = getProfileCookie(req);
|
||||||
|
const profile = PROFILES.find((p) => p.id === wanted) || PROFILES[0];
|
||||||
|
return createRuntime(profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
function splitAwgConf(text) {
|
function splitAwgConf(text) {
|
||||||
@@ -267,40 +387,56 @@ function stringifyClientsTable(rows) {
|
|||||||
return `${JSON.stringify(rows, null, 4)}\n`;
|
return `${JSON.stringify(rows, null, 4)}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function backupRemoteFiles() {
|
async function disableClient(rt, clientId, ts) {
|
||||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
await rt.backupRemoteFiles();
|
||||||
await dockerExec(
|
const { conf, clients } = await rt.loadState();
|
||||||
`cp '${AWG_CONF}' '${AWG_CONF}.bak-admin-${stamp}' 2>/dev/null || true`
|
const peer = conf.peers.find((p) => p.publicKey === clientId);
|
||||||
);
|
if (!peer) {
|
||||||
await dockerExec(
|
throw new Error("Peer not in config (already disabled?)");
|
||||||
`cp '${CLIENTS_JSON}' '${CLIENTS_JSON}.bak-admin-${stamp}' 2>/dev/null || true`
|
}
|
||||||
);
|
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) throw new Error("Client not in clientsTable");
|
||||||
|
const ud = { ...(clients[idx].userData || {}) };
|
||||||
|
ud.disabled = true;
|
||||||
|
ud.disabledAt = ts;
|
||||||
|
ud.lastDisconnectedAt = ts;
|
||||||
|
delete ud.scheduledTunnelDisconnectAt;
|
||||||
|
ud.preservedPresharedKey = peer.presharedKey || ud.preservedPresharedKey;
|
||||||
|
ud.preservedAllowedIPs = peer.allowedIPs || ud.preservedAllowedIPs;
|
||||||
|
clients[idx] = { ...clients[idx], userData: ud };
|
||||||
|
await rt.dockerWriteFile(rt.confPath, nextConfText);
|
||||||
|
await rt.dockerWriteFile(rt.clientsPath, stringifyClientsTable(clients));
|
||||||
|
await rt.applySyncconf();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applySyncconf() {
|
async function processScheduledDisconnects(rt) {
|
||||||
await dockerExec(
|
const now = Date.now();
|
||||||
`wg-quick strip '${AWG_CONF}' > /tmp/wg-admin-strip.conf && awg syncconf awg0 /tmp/wg-admin-strip.conf`
|
const { clients, peerByKey } = await rt.loadState();
|
||||||
);
|
const due = [];
|
||||||
}
|
for (const c of clients) {
|
||||||
|
const ud = c.userData || {};
|
||||||
async function loadState() {
|
const iso = ud.scheduledTunnelDisconnectAt;
|
||||||
const [confText, tableText] = await Promise.all([
|
if (!iso || !peerByKey.get(c.clientId)) continue;
|
||||||
dockerReadFile(AWG_CONF),
|
const t = new Date(iso).getTime();
|
||||||
dockerReadFile(CLIENTS_JSON),
|
if (Number.isNaN(t) || t > now) continue;
|
||||||
]);
|
due.push({ clientId: c.clientId, ts: new Date(iso).toISOString() });
|
||||||
const conf = splitAwgConf(confText);
|
}
|
||||||
const clients = parseClientsTable(tableText);
|
if (!due.length) return;
|
||||||
const peerByKey = new Map(conf.peers.map((p) => [p.publicKey, p]));
|
await rt.backupRemoteFiles();
|
||||||
return { confText, conf, clients, peerByKey };
|
for (const { clientId, ts } of due) {
|
||||||
}
|
|
||||||
|
|
||||||
async function inferPskFromConf(conf) {
|
|
||||||
if (conf.peers.length) return conf.peers[0].presharedKey;
|
|
||||||
try {
|
try {
|
||||||
const text = await dockerReadFile("/opt/amnezia/awg/wireguard_psk.key");
|
await disableClient(rt, clientId, ts);
|
||||||
return text.trim();
|
} catch (e) {
|
||||||
} catch {
|
console.error(`scheduled off ${clientId} [${rt.profile.id}]:`, e);
|
||||||
return null;
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processAllScheduledDisconnects() {
|
||||||
|
for (const profile of PROFILES) {
|
||||||
|
await processScheduledDisconnects(createRuntime(profile));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,15 +540,39 @@ app.post("/api/change-password", requireAuth, (req, res) => {
|
|||||||
res.json({ ok: true, message: "Пароль изменён. Войдите снова." });
|
res.json({ ok: true, message: "Пароль изменён. Войдите снова." });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/clients", requireAuth, async (_req, res) => {
|
app.get("/api/protocols", requireAuth, (req, res) => {
|
||||||
|
const rt = runtimeForRequest(req);
|
||||||
|
res.json({
|
||||||
|
currentId: rt.profile.id,
|
||||||
|
currentLabel: rt.profile.label,
|
||||||
|
profiles: PROFILES.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
label: p.label,
|
||||||
|
container: p.container,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/protocol", requireAuth, (req, res) => {
|
||||||
|
const id = req.body?.profileId;
|
||||||
|
if (typeof id !== "string" || !PROFILES.some((p) => p.id === id)) {
|
||||||
|
res.status(400).json({ error: "Неизвестный profileId" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setProfileCookie(res, id);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/clients", requireAuth, async (req, res) => {
|
||||||
|
const rt = runtimeForRequest(req);
|
||||||
try {
|
try {
|
||||||
let wgShow = "";
|
let wgShow = "";
|
||||||
try {
|
try {
|
||||||
wgShow = await dockerExec(`awg show awg0`);
|
wgShow = await rt.dockerExec(`${rt.profile.wgBinary} show ${rt.profile.iface}`);
|
||||||
} catch {
|
} catch {
|
||||||
wgShow = "";
|
wgShow = "";
|
||||||
}
|
}
|
||||||
const { conf, clients, peerByKey } = await loadState();
|
const { conf, clients, peerByKey } = await rt.loadState();
|
||||||
const rows = clients.map((c) => {
|
const rows = clients.map((c) => {
|
||||||
const id = c.clientId;
|
const id = c.clientId;
|
||||||
const peer = peerByKey.get(id);
|
const peer = peerByKey.get(id);
|
||||||
@@ -426,6 +586,7 @@ app.get("/api/clients", requireAuth, async (_req, res) => {
|
|||||||
disabled: !activeInConf,
|
disabled: !activeInConf,
|
||||||
disabledAt: ud.disabledAt || null,
|
disabledAt: ud.disabledAt || null,
|
||||||
lastDisconnectedAt: ud.lastDisconnectedAt || null,
|
lastDisconnectedAt: ud.lastDisconnectedAt || null,
|
||||||
|
scheduledTunnelDisconnectAt: ud.scheduledTunnelDisconnectAt || null,
|
||||||
creationDate: ud.creationDate || null,
|
creationDate: ud.creationDate || null,
|
||||||
latestHandshake: ud.latestHandshake || null,
|
latestHandshake: ud.latestHandshake || null,
|
||||||
dataReceived: ud.dataReceived || null,
|
dataReceived: ud.dataReceived || null,
|
||||||
@@ -433,7 +594,9 @@ app.get("/api/clients", requireAuth, async (_req, res) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
res.json({
|
res.json({
|
||||||
container: CONTAINER,
|
profileId: rt.profile.id,
|
||||||
|
profileLabel: rt.profile.label,
|
||||||
|
container: rt.profile.container,
|
||||||
protocol: "AmneziaWG",
|
protocol: "AmneziaWG",
|
||||||
peerCount: conf.peers.length,
|
peerCount: conf.peers.length,
|
||||||
clients: rows,
|
clients: rows,
|
||||||
@@ -446,6 +609,7 @@ app.get("/api/clients", requireAuth, async (_req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/clients/disable", requireAuth, async (req, res) => {
|
app.post("/api/clients/disable", requireAuth, async (req, res) => {
|
||||||
|
const rt = runtimeForRequest(req);
|
||||||
const clientId = req.body?.clientId;
|
const clientId = req.body?.clientId;
|
||||||
if (!clientId) return res.status(400).json({ error: "clientId required" });
|
if (!clientId) return res.status(400).json({ error: "clientId required" });
|
||||||
let ts;
|
let ts;
|
||||||
@@ -455,41 +619,25 @@ app.post("/api/clients/disable", requireAuth, async (req, res) => {
|
|||||||
return res.status(400).json({ error: String(e.message || e) });
|
return res.status(400).json({ error: String(e.message || e) });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await backupRemoteFiles();
|
await disableClient(rt, clientId, ts);
|
||||||
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 });
|
res.json({ ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
const msg = String(e.message || e);
|
||||||
|
if (msg.includes("already disabled") || msg.includes("Peer not in config")) {
|
||||||
|
return res.status(404).json({ error: msg });
|
||||||
|
}
|
||||||
console.error(e);
|
console.error(e);
|
||||||
res.status(500).json({ error: String(e.message || e) });
|
res.status(500).json({ error: msg });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/clients/enable", requireAuth, async (req, res) => {
|
app.post("/api/clients/enable", requireAuth, async (req, res) => {
|
||||||
|
const rt = runtimeForRequest(req);
|
||||||
const clientId = req.body?.clientId;
|
const clientId = req.body?.clientId;
|
||||||
if (!clientId) return res.status(400).json({ error: "clientId required" });
|
if (!clientId) return res.status(400).json({ error: "clientId required" });
|
||||||
try {
|
try {
|
||||||
await backupRemoteFiles();
|
await rt.backupRemoteFiles();
|
||||||
const { conf, clients } = await loadState();
|
const { conf, clients } = await rt.loadState();
|
||||||
const existing = conf.peers.find((p) => p.publicKey === clientId);
|
const existing = conf.peers.find((p) => p.publicKey === clientId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return res.status(409).json({ error: "Peer already enabled" });
|
return res.status(409).json({ error: "Peer already enabled" });
|
||||||
@@ -502,7 +650,7 @@ app.post("/api/clients/enable", requireAuth, async (req, res) => {
|
|||||||
const psk =
|
const psk =
|
||||||
ud.preservedPresharedKey ||
|
ud.preservedPresharedKey ||
|
||||||
conf.peers[0]?.presharedKey ||
|
conf.peers[0]?.presharedKey ||
|
||||||
(await inferPskFromConf(conf));
|
(await rt.inferPskFromConf(conf));
|
||||||
const ips = ud.preservedAllowedIPs || ud.allowedIps;
|
const ips = ud.preservedAllowedIPs || ud.allowedIps;
|
||||||
if (!psk || !ips) {
|
if (!psk || !ips) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -519,12 +667,13 @@ AllowedIPs = ${ips}`;
|
|||||||
const nextConfText = serializeAwgConf(conf.head, nextPeers);
|
const nextConfText = serializeAwgConf(conf.head, nextPeers);
|
||||||
delete ud.disabled;
|
delete ud.disabled;
|
||||||
delete ud.disabledAt;
|
delete ud.disabledAt;
|
||||||
|
delete ud.scheduledTunnelDisconnectAt;
|
||||||
delete ud.preservedPresharedKey;
|
delete ud.preservedPresharedKey;
|
||||||
delete ud.preservedAllowedIPs;
|
delete ud.preservedAllowedIPs;
|
||||||
clients[idx] = { ...clients[idx], userData: ud };
|
clients[idx] = { ...clients[idx], userData: ud };
|
||||||
await dockerWriteFile(AWG_CONF, nextConfText);
|
await rt.dockerWriteFile(rt.confPath, nextConfText);
|
||||||
await dockerWriteFile(CLIENTS_JSON, stringifyClientsTable(clients));
|
await rt.dockerWriteFile(rt.clientsPath, stringifyClientsTable(clients));
|
||||||
await applySyncconf();
|
await rt.applySyncconf();
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -533,6 +682,7 @@ AllowedIPs = ${ips}`;
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/clients/disconnect-date", requireAuth, async (req, res) => {
|
app.post("/api/clients/disconnect-date", requireAuth, async (req, res) => {
|
||||||
|
const rt = runtimeForRequest(req);
|
||||||
const clientId = req.body?.clientId;
|
const clientId = req.body?.clientId;
|
||||||
if (!clientId) return res.status(400).json({ error: "clientId required" });
|
if (!clientId) return res.status(400).json({ error: "clientId required" });
|
||||||
let iso;
|
let iso;
|
||||||
@@ -541,18 +691,29 @@ app.post("/api/clients/disconnect-date", requireAuth, async (req, res) => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
return res.status(400).json({ error: String(e.message || e) });
|
return res.status(400).json({ error: String(e.message || e) });
|
||||||
}
|
}
|
||||||
|
const scheduleTunnelDisconnect = Boolean(req.body?.scheduleTunnelDisconnect);
|
||||||
try {
|
try {
|
||||||
const { conf, clients, peerByKey } = await loadState();
|
const { clients, peerByKey } = await rt.loadState();
|
||||||
const idx = clients.findIndex((c) => c.clientId === clientId);
|
const idx = clients.findIndex((c) => c.clientId === clientId);
|
||||||
if (idx === -1) return res.status(404).json({ error: "Client not in clientsTable" });
|
if (idx === -1) return res.status(404).json({ error: "Client not in clientsTable" });
|
||||||
const peer = peerByKey.get(clientId);
|
const peer = peerByKey.get(clientId);
|
||||||
const ud = { ...(clients[idx].userData || {}) };
|
const ud = { ...(clients[idx].userData || {}) };
|
||||||
|
if (scheduleTunnelDisconnect) {
|
||||||
|
if (!peer) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Клиент не в туннеле — отложенное отключение недоступно",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ud.scheduledTunnelDisconnectAt = iso;
|
||||||
|
} else {
|
||||||
|
delete ud.scheduledTunnelDisconnectAt;
|
||||||
ud.lastDisconnectedAt = iso;
|
ud.lastDisconnectedAt = iso;
|
||||||
if (!peer) {
|
if (!peer) {
|
||||||
ud.disabledAt = iso;
|
ud.disabledAt = iso;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
clients[idx] = { ...clients[idx], userData: ud };
|
clients[idx] = { ...clients[idx], userData: ud };
|
||||||
await dockerWriteFile(CLIENTS_JSON, stringifyClientsTable(clients));
|
await rt.dockerWriteFile(rt.clientsPath, stringifyClientsTable(clients));
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -561,6 +722,7 @@ app.post("/api/clients/disconnect-date", requireAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/clients/rename", requireAuth, async (req, res) => {
|
app.post("/api/clients/rename", requireAuth, async (req, res) => {
|
||||||
|
const rt = runtimeForRequest(req);
|
||||||
const clientId = req.body?.clientId;
|
const clientId = req.body?.clientId;
|
||||||
const rawName = req.body?.name ?? req.body?.clientName;
|
const rawName = req.body?.name ?? req.body?.clientName;
|
||||||
if (!clientId) return res.status(400).json({ error: "clientId required" });
|
if (!clientId) return res.status(400).json({ error: "clientId required" });
|
||||||
@@ -573,12 +735,12 @@ app.post("/api/clients/rename", requireAuth, async (req, res) => {
|
|||||||
return res.status(400).json({ error: "Имя не длиннее 200 символов" });
|
return res.status(400).json({ error: "Имя не длиннее 200 символов" });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const { clients } = await loadState();
|
const { clients } = await rt.loadState();
|
||||||
const idx = clients.findIndex((c) => c.clientId === clientId);
|
const idx = clients.findIndex((c) => c.clientId === clientId);
|
||||||
if (idx === -1) return res.status(404).json({ error: "Client not in clientsTable" });
|
if (idx === -1) return res.status(404).json({ error: "Client not in clientsTable" });
|
||||||
const ud = { ...(clients[idx].userData || {}), clientName: name };
|
const ud = { ...(clients[idx].userData || {}), clientName: name };
|
||||||
clients[idx] = { ...clients[idx], userData: ud };
|
clients[idx] = { ...clients[idx], userData: ud };
|
||||||
await dockerWriteFile(CLIENTS_JSON, stringifyClientsTable(clients));
|
await rt.dockerWriteFile(rt.clientsPath, stringifyClientsTable(clients));
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -587,20 +749,21 @@ app.post("/api/clients/rename", requireAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/clients/delete", requireAuth, async (req, res) => {
|
app.post("/api/clients/delete", requireAuth, async (req, res) => {
|
||||||
|
const rt = runtimeForRequest(req);
|
||||||
const clientId = req.body?.clientId;
|
const clientId = req.body?.clientId;
|
||||||
if (!clientId) return res.status(400).json({ error: "clientId required" });
|
if (!clientId) return res.status(400).json({ error: "clientId required" });
|
||||||
try {
|
try {
|
||||||
await backupRemoteFiles();
|
await rt.backupRemoteFiles();
|
||||||
const { conf, clients } = await loadState();
|
const { conf, clients } = await rt.loadState();
|
||||||
const nextPeers = conf.peers.filter((p) => p.publicKey !== clientId);
|
const nextPeers = conf.peers.filter((p) => p.publicKey !== clientId);
|
||||||
const nextClients = clients.filter((c) => c.clientId !== clientId);
|
const nextClients = clients.filter((c) => c.clientId !== clientId);
|
||||||
if (nextClients.length === clients.length) {
|
if (nextClients.length === clients.length) {
|
||||||
return res.status(404).json({ error: "Client not in clientsTable" });
|
return res.status(404).json({ error: "Client not in clientsTable" });
|
||||||
}
|
}
|
||||||
const nextConfText = serializeAwgConf(conf.head, nextPeers);
|
const nextConfText = serializeAwgConf(conf.head, nextPeers);
|
||||||
await dockerWriteFile(AWG_CONF, nextConfText);
|
await rt.dockerWriteFile(rt.confPath, nextConfText);
|
||||||
await dockerWriteFile(CLIENTS_JSON, stringifyClientsTable(nextClients));
|
await rt.dockerWriteFile(rt.clientsPath, stringifyClientsTable(nextClients));
|
||||||
await applySyncconf();
|
await rt.applySyncconf();
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -627,5 +790,14 @@ app.use((_req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.listen(PORT, "0.0.0.0", () => {
|
app.listen(PORT, "0.0.0.0", () => {
|
||||||
console.log(`amnezia-admin on :${PORT} → docker:${CONTAINER}, data:${DATA_DIR}`);
|
const summary = PROFILES.map((p) => `${p.label}→${p.container}`).join("; ");
|
||||||
|
console.log(`amnezia-admin on :${PORT} · ${summary} · data:${DATA_DIR}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
processAllScheduledDisconnects().catch((e) => console.error("scheduleDisconnect:", e));
|
||||||
|
}, SCHEDULER_MS);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
processAllScheduledDisconnects().catch((e) => console.error("scheduleDisconnect:", e));
|
||||||
|
}, 4000);
|
||||||
|
|||||||
Reference in New Issue
Block a user