Compare commits
6
Commits
v0.4.1
...
28ccc17bde
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28ccc17bde | ||
|
|
d6fc2049c3 | ||
|
|
ea91265674 | ||
|
|
de8a32ac41 | ||
|
|
a84845cecb | ||
|
|
c73da602d6 |
@@ -11,4 +11,4 @@
|
|||||||
|
|
||||||
BRAND=Forgetting
|
BRAND=Forgetting
|
||||||
CHANNEL=Beta
|
CHANNEL=Beta
|
||||||
VERSION=0.4.1
|
VERSION=0.4.3
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ TOKEN=$(curl -s -X POST http://HOST:PORT/login \
|
|||||||
|
|
||||||
| Метод | Ответ |
|
| Метод | Ответ |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.4.1" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
|
| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.4.3" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
|
||||||
| `GET /health` | `{ "status", "server", "ip", "gen", "awg": { "status", "peers", "module", "tools" } }`. `200`, если интерфейс поднят, иначе `503` и `status: "degraded"` |
|
| `GET /health` | `{ "status", "server", "ip", "gen", "awg": { "status", "peers", "module", "tools" } }`. `200`, если интерфейс поднят, иначе `503` и `status: "degraded"` |
|
||||||
| `GET /awg/status` | `{ "up": true, "peers": 3, "publicKey": "…" }` |
|
| `GET /awg/status` | `{ "up": true, "peers": 3, "publicKey": "…" }` |
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "awg-agent",
|
"name": "awg-agent",
|
||||||
"version": "0.4.1",
|
"version": "0.4.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Агент ноды: держит исходящее WSS-соединение к core и исполняет запросы на локальном awg-ctrl",
|
"description": "Агент ноды: держит исходящее WSS-соединение к core и исполняет запросы на локальном awg-ctrl",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "awg-control",
|
"name": "awg-control",
|
||||||
"version": "0.4.1",
|
"version": "0.4.3",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+35
-4
@@ -57,7 +57,11 @@ const masters: MK[] = [{ id: mkId++, uuid: crypto.randomUUID(), label: "Семь
|
|||||||
const signPub = crypto.randomBytes(32);
|
const signPub = crypto.randomBytes(32);
|
||||||
const mkOut = (m: MK) => ({ id: m.id, uuid: m.uuid, label: m.label, device_limit: m.device_limit, devices: m.devs.length, servers: m.servers, created_at: m.created_at, ...(m.deleting ? { deleting: m.deleting } : {}) });
|
const mkOut = (m: MK) => ({ id: m.id, uuid: m.uuid, label: m.label, device_limit: m.device_limit, devices: m.devs.length, servers: m.servers, created_at: m.created_at, ...(m.deleting ? { deleting: m.deleting } : {}) });
|
||||||
// Мок: устройство «догнано» на всех серверах ключа, кроме Tokyo (id 2 — ещё не подключалась).
|
// Мок: устройство «догнано» на всех серверах ключа, кроме Tokyo (id 2 — ещё не подключалась).
|
||||||
const devOut = (m: MK, d: Dev) => ({ ...d, priority: shapingOf[0]?.enabled && d.id === masters[0]?.devs[0]?.id ? "background" : "normal", servers_total: m.servers.length, servers_ok: m.servers.filter(s => s !== 2).length });
|
const devTorrent = (d: Dev) => !!torrentOf[0]?.enabled && d.id === masters[0]?.devs[1]?.id;
|
||||||
|
const devOut = (m: MK, d: Dev) => ({ ...d,
|
||||||
|
priority: shapingOf[0]?.enabled && (d.id === masters[0]?.devs[0]?.id || devTorrent(d)) ? "background" : "normal",
|
||||||
|
torrent: devTorrent(d), servers_total: m.servers.length, servers_ok: m.servers.filter(s => s !== 2).length,
|
||||||
|
ips: m.servers.filter(s => s !== 2).map(s => ({ server_id: s, ip: `10.9.0.${50 + d.id + s * 10}` })) });
|
||||||
const find = (id: string) => masters.find(m => m.id === Number(id) && !m.deleting);
|
const find = (id: string) => masters.find(m => m.id === Number(id) && !m.deleting);
|
||||||
|
|
||||||
app.get("/ui/brand", (_q, r) => r.json({ brand: "Forgetting", channel: "Beta", version: MOCK_VERSION }));
|
app.get("/ui/brand", (_q, r) => r.json({ brand: "Forgetting", channel: "Beta", version: MOCK_VERSION }));
|
||||||
@@ -165,7 +169,8 @@ const shapingView = (id: number) => {
|
|||||||
const settings = shapingOf[id] ?? { enabled: false, downMbit: 0, upMbit: 0, auto: true };
|
const settings = shapingOf[id] ?? { enabled: false, downMbit: 0, upMbit: 0, auto: true };
|
||||||
const on = settings.enabled;
|
const on = settings.enabled;
|
||||||
return {
|
return {
|
||||||
settings, active: on, congested: on && id === 0, heavy: on && id === 0 ? ["10.9.0.2"] : [],
|
settings, active: on, congested: on && id === 0,
|
||||||
|
heavy: on && id === 0 ? ["10.9.0.2", ...(torrentOf[0]?.enabled ? [TORRENT_IP, "10.9.0.12"] : [])] : [],
|
||||||
devices: on ? 7 : 0, rateDown: on ? Math.round(settings.downMbit * 0.9 * 10) / 10 : 0, rateUp: on ? 12.4 : 0,
|
devices: on ? 7 : 0, rateDown: on ? Math.round(settings.downMbit * 0.9 * 10) / 10 : 0, rateUp: on ? 12.4 : 0,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -178,10 +183,36 @@ app.put("/api/shaping", (q, r) => {
|
|||||||
r.json(shapingView(id));
|
r.json(shapingView(id));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Блокировка торрентов: на сервере 0 качает пользователь 10.9.0.3 и второе устройство первого мастер-ключа;
|
||||||
|
// Amsterdam (3) изображает старый nftables без TCP-проверки; Frankfurt (1) — старая нода без маршрута (404).
|
||||||
|
// Сервер берём прямо из X-Server-Id: srv() подменяет сервер без своих пользователей на 0.
|
||||||
|
const torrentOf: Record<number, { enabled: boolean; since: number }> = {};
|
||||||
|
const tsid = (q: express.Request) => Number(q.headers["x-server-id"] ?? 0) || 0;
|
||||||
|
const TORRENT_IP = "10.9.0.3";
|
||||||
|
const torrentSeenIp = (id: number, ip: string) => id === 0 && !!torrentOf[0]?.enabled && ip === TORRENT_IP;
|
||||||
|
const torrentView = (id: number) => {
|
||||||
|
const t = torrentOf[id] ?? { enabled: false, since: 0 };
|
||||||
|
const seen = id === 0 && t.enabled
|
||||||
|
? [{ ip: TORRENT_IP, name: users.find(u => u.ip === TORRENT_IP)?.name ?? "" }, { ip: "10.9.0.12", name: "" }]
|
||||||
|
: [];
|
||||||
|
return { settings: { enabled: t.enabled }, active: t.enabled, tcp: t.enabled && id !== 3,
|
||||||
|
hits: t.enabled && id === 0 ? 1840 + Math.floor((now() - t.since) * 3.7) : 0, seen };
|
||||||
|
};
|
||||||
|
app.get("/api/torrent", (q, r) => tsid(q) === 1 ? r.status(404).json({ error: "Not found" }) : r.json(torrentView(tsid(q))));
|
||||||
|
app.put("/api/torrent", (q, r) => {
|
||||||
|
const id = tsid(q), b = q.body ?? {};
|
||||||
|
if (id === 1) return r.status(404).json({ error: "Not found" });
|
||||||
|
if (typeof b.enabled !== "boolean") return r.status(400).json({ error: "enabled должен быть true/false" });
|
||||||
|
torrentOf[id] = { enabled: b.enabled, since: b.enabled && !torrentOf[id]?.enabled ? now() : (torrentOf[id]?.since ?? now()) };
|
||||||
|
r.json(torrentView(id));
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/api/users", (q, r) => r.json({ users: U_(q) }));
|
app.get("/api/users", (q, r) => r.json({ users: U_(q) }));
|
||||||
// Приоритет в моке: пока на сервере 0 включено деление канала, первый пользователь «фоновый».
|
// Приоритет в моке: пока на сервере 0 включено деление канала, первый пользователь «фоновый».
|
||||||
const prioOf = (q: express.Request, ip: string) => (srv(q) === 0 && shapingOf[0]?.enabled && ip === "10.9.0.2" ? "background" : "normal");
|
// С меткой «торренты» — тоже фоновый (пока включено деление канала).
|
||||||
app.get("/api/users/stats", (q, r) => r.json({ users: U_(q).map(({ vpn_key, pub_key, ...u }) => ({ ...u, priority: prioOf(q, u.ip) })) }));
|
const prioOf = (q: express.Request, ip: string) =>
|
||||||
|
(srv(q) === 0 && shapingOf[0]?.enabled && (ip === "10.9.0.2" || torrentSeenIp(tsid(q), ip)) ? "background" : "normal");
|
||||||
|
app.get("/api/users/stats", (q, r) => r.json({ users: U_(q).map(({ vpn_key, pub_key, ...u }) => ({ ...u, priority: prioOf(q, u.ip), torrent: torrentSeenIp(tsid(q), u.ip) })) }));
|
||||||
app.post("/api/users/reissue", (q, r) => r.json({ total: U_(q).length, reissued: U_(q).length, regenerated: [], backup: "" }));
|
app.post("/api/users/reissue", (q, r) => r.json({ total: U_(q).length, reissued: U_(q).length, regenerated: [], backup: "" }));
|
||||||
app.post("/api/users", (q, r) => {
|
app.post("/api/users", (q, r) => {
|
||||||
const users = U_(q);
|
const users = U_(q);
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "awg-ui",
|
"name": "awg-ui",
|
||||||
"version": "0.4.1",
|
"version": "0.4.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -417,6 +417,7 @@ body {
|
|||||||
}
|
}
|
||||||
.node-modal {
|
.node-modal {
|
||||||
width: 100%; max-width: 520px;
|
width: 100%; max-width: 520px;
|
||||||
|
max-height: 100%; overflow-y: auto;
|
||||||
background: var(--surface-bright);
|
background: var(--surface-bright);
|
||||||
border: 1px solid var(--outline-variant);
|
border: 1px solid var(--outline-variant);
|
||||||
border-radius: var(--r-card);
|
border-radius: var(--r-card);
|
||||||
@@ -445,6 +446,9 @@ body {
|
|||||||
.ss-field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
.ss-field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
||||||
.ss-status { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; background: var(--surface-container); border-radius: var(--r-table); padding: 12px; }
|
.ss-status { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; background: var(--surface-container); border-radius: var(--r-table); padding: 12px; }
|
||||||
.ss-mono { font-family: var(--mono); font-size: 12px; color: var(--neutral-text); word-break: break-all; }
|
.ss-mono { font-family: var(--mono); font-size: 12px; color: var(--neutral-text); word-break: break-all; }
|
||||||
|
.ss-section { margin: 4px 0 0; font-size: 14px; font-weight: 500; color: var(--on-surface); }
|
||||||
|
.ss-divider { border: 0; border-top: 1px solid var(--outline-variant); margin: 4px 0; }
|
||||||
|
.ss-seen { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
/* Тёплый чип «внимание»: канал занят, фоновый приоритет */
|
/* Тёплый чип «внимание»: канал занят, фоновый приоритет */
|
||||||
.chip--warm { background: var(--tertiary-container); color: var(--on-tertiary-container); }
|
.chip--warm { background: var(--tertiary-container); color: var(--on-tertiary-container); }
|
||||||
.chip--warm .chip-dot { background: var(--on-tertiary-container); }
|
.chip--warm .chip-dot { background: var(--on-tertiary-container); }
|
||||||
@@ -781,3 +785,14 @@ tbody td:last-child { padding-right: 20px; }
|
|||||||
.server-pick-name { font-weight: 500; }
|
.server-pick-name { font-weight: 500; }
|
||||||
.server-pick-ip, .server-pick-gen { font-size: 12px; color: var(--neutral-text); }
|
.server-pick-ip, .server-pick-gen { font-size: 12px; color: var(--neutral-text); }
|
||||||
.server-pick-ip { font-family: var(--mono); margin-left: auto; }
|
.server-pick-ip { font-family: var(--mono); margin-left: auto; }
|
||||||
|
|
||||||
|
/* Короткий идентификатор под именем (UUID мастер-ключа, ID устройства):
|
||||||
|
первые символы моноширинным, полностью — в подсказке, по клику копируется. */
|
||||||
|
.name-stack { display: inline-flex; flex-direction: column; line-height: 1.25; vertical-align: middle; }
|
||||||
|
.copy-id {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: none; border: 0; padding: 0; margin: 0; cursor: pointer;
|
||||||
|
font-family: var(--mono); font-size: 11px; font-weight: 400; color: var(--neutral-text);
|
||||||
|
}
|
||||||
|
.copy-id:hover { color: var(--primary); }
|
||||||
|
.copy-id:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; border-radius: 4px; }
|
||||||
|
|||||||
@@ -149,6 +149,24 @@ export default function App() {
|
|||||||
if (touchStartX.current - touchCurrentX.current > 40) setDrawerOpen(false);
|
if (touchStartX.current - touchCurrentX.current > 40) setDrawerOpen(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Токен протух (24 ч) или отозван, пока панель открыта: запросы вкладок падали с 401, а экран
|
||||||
|
// оставался прежним. Выходим только по 401 самой панели («Unauthorized» — её JWT не принят):
|
||||||
|
// 401 от awg-ctrl ноды (внутренняя авторизация) пересылается как есть и сессию не рвёт.
|
||||||
|
useEffect(() => {
|
||||||
|
const id = axios.interceptors.response.use(undefined, e => {
|
||||||
|
if (axios.isAxiosError(e) && e.response?.status === 401 && e.response.data?.error === 'Unauthorized') {
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
setToken('');
|
||||||
|
setNodes([]);
|
||||||
|
setServerId(null);
|
||||||
|
setServerIdState(null);
|
||||||
|
showMsg('Сессия истекла — войдите снова');
|
||||||
|
}
|
||||||
|
return Promise.reject(e);
|
||||||
|
});
|
||||||
|
return () => axios.interceptors.response.eject(id);
|
||||||
|
}, [showMsg]);
|
||||||
|
|
||||||
// Восстановить сессию из сохранённого токена
|
// Восстановить сессию из сохранённого токена
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const saved = localStorage.getItem(TOKEN_KEY);
|
const saved = localStorage.getItem(TOKEN_KEY);
|
||||||
|
|||||||
@@ -6,16 +6,19 @@ import type { Priority } from '../lib/shared';
|
|||||||
// Текущая приоритетность ключа/устройства. «Фоновый» — только пока канал занят, а устройство давно
|
// Текущая приоритетность ключа/устройства. «Фоновый» — только пока канал занят, а устройство давно
|
||||||
// качает заметно больше своей доли: оно уступает остальным, но свободный канал занимает по-прежнему.
|
// качает заметно больше своей доли: оно уступает остальным, но свободный канал занимает по-прежнему.
|
||||||
// Когда деление канала выключено или канал свободен, у всех «обычный». Без поля (awg-ctrl старше
|
// Когда деление канала выключено или канал свободен, у всех «обычный». Без поля (awg-ctrl старше
|
||||||
// деления канала) чип не показываем: «обычный» там было бы выдумкой.
|
// деления канала) чип не показываем: «обычный» там было бы выдумкой. С меткой «торренты» устройство
|
||||||
export default function PriorityChip({ priority }: { priority?: Priority }) {
|
// фоновое независимо от загрузки канала — подсказка говорит об этом.
|
||||||
|
export default function PriorityChip({ priority, torrent }: { priority?: Priority; torrent?: boolean }) {
|
||||||
if (!priority) return null;
|
if (!priority) return null;
|
||||||
const bg = priority === 'background';
|
const bg = priority === 'background';
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="tip-wrap"
|
className="tip-wrap"
|
||||||
data-tip={bg
|
data-tip={!bg
|
||||||
? 'Канал занят, а это устройство давно качает больше своей доли — пока оно уступает остальным'
|
? 'Обычный приоритет: канал делится поровну'
|
||||||
: 'Обычный приоритет: канал делится поровну'}
|
: torrent
|
||||||
|
? 'Пытается качать торренты — пока висит метка, устройство уступает остальным'
|
||||||
|
: 'Канал занят, а это устройство давно качает больше своей доли — пока оно уступает остальным'}
|
||||||
>
|
>
|
||||||
<span className={`chip ${bg ? 'chip--warm' : 'chip--offline'}`}>
|
<span className={`chip ${bg ? 'chip--warm' : 'chip--offline'}`}>
|
||||||
<span className="chip-dot" />{bg ? 'фоновый' : 'обычный'}
|
<span className="chip-dot" />{bg ? 'фоновый' : 'обычный'}
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
// LICENSE file in the root directory of this source tree.
|
// LICENSE file in the root directory of this source tree.
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { apiFetch, type NodeInfo, type ShapingStatus } from '../lib/shared';
|
import { apiFetch, type NodeInfo, type ShapingStatus, type TorrentStatus } from '../lib/shared';
|
||||||
|
|
||||||
// Настройки сервера: деление канала между устройствами (CAKE). Сами настройки живут в
|
// Настройки сервера: деление канала между устройствами (CAKE) и блокировка BitTorrent. Сами
|
||||||
// awg-ctrl этого сервера — запрос идёт обычным прокси с X-Server-Id (App выбирает сервер
|
// настройки живут в awg-ctrl этого сервера — запрос идёт обычным прокси с X-Server-Id (App выбирает сервер
|
||||||
// до открытия модалки), так что ядро и ноды настраиваются одинаково.
|
// до открытия модалки), так что ядро и ноды настраиваются одинаково.
|
||||||
export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
|
export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
|
||||||
token: string;
|
token: string;
|
||||||
@@ -22,6 +22,9 @@ export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [unsupported, setUnsupported] = useState(false);
|
const [unsupported, setUnsupported] = useState(false);
|
||||||
|
const [tst, setTst] = useState<TorrentStatus | null>(null);
|
||||||
|
const [tEnabled, setTEnabled] = useState(false);
|
||||||
|
const [tUnsupported, setTUnsupported] = useState(false);
|
||||||
|
|
||||||
const fill = (s: ShapingStatus) => {
|
const fill = (s: ShapingStatus) => {
|
||||||
setSt(s);
|
setSt(s);
|
||||||
@@ -33,6 +36,20 @@ export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
|
|||||||
const errText = (e: unknown, fallback: string) =>
|
const errText = (e: unknown, fallback: string) =>
|
||||||
axios.isAxiosError(e) ? e.response?.data?.error ?? fallback : fallback;
|
axios.isAxiosError(e) ? e.response?.data?.error ?? fallback : fallback;
|
||||||
|
|
||||||
|
const fillTorrent = (s: TorrentStatus) => { setTst(s); setTEnabled(s.settings.enabled); };
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
apiFetch('GET', '/api/torrent', token)
|
||||||
|
.then(s => { if (alive) fillTorrent(s as TorrentStatus); })
|
||||||
|
.catch(e => {
|
||||||
|
if (!alive) return;
|
||||||
|
if (axios.isAxiosError(e) && e.response?.status === 404) setTUnsupported(true);
|
||||||
|
else setError(errText(e, 'Не удалось загрузить настройки'));
|
||||||
|
});
|
||||||
|
return () => { alive = false; };
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
apiFetch('GET', '/api/shaping', token)
|
apiFetch('GET', '/api/shaping', token)
|
||||||
@@ -55,14 +72,28 @@ export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
|
|||||||
const downN = Number(down), upN = Number(up);
|
const downN = Number(down), upN = Number(up);
|
||||||
const valid = !enabled || (Number.isInteger(downN) && downN >= 1 && Number.isInteger(upN) && upN >= 1);
|
const valid = !enabled || (Number.isInteger(downN) && downN >= 1 && Number.isInteger(upN) && upN >= 1);
|
||||||
|
|
||||||
|
// Отправляем только изменённое: лишний PUT /api/shaping заново ставил бы tc на интерфейс.
|
||||||
|
const shapingDirty = !!st && (enabled !== st.settings.enabled || auto !== st.settings.auto
|
||||||
|
|| (Number(down) || 0) !== st.settings.downMbit || (Number(up) || 0) !== st.settings.upMbit);
|
||||||
|
const torrentDirty = !!tst && tEnabled !== tst.settings.enabled;
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
setBusy(true); setError('');
|
setBusy(true); setError('');
|
||||||
|
const done: string[] = [];
|
||||||
try {
|
try {
|
||||||
const s = await apiFetch('PUT', '/api/shaping', token, {
|
if (shapingDirty) {
|
||||||
enabled, auto, downMbit: Number(down) || 0, upMbit: Number(up) || 0,
|
const s = await apiFetch('PUT', '/api/shaping', token, {
|
||||||
});
|
enabled, auto, downMbit: Number(down) || 0, upMbit: Number(up) || 0,
|
||||||
fill(s as ShapingStatus);
|
});
|
||||||
showMsg(enabled ? 'Деление канала включено' : 'Деление канала выключено');
|
fill(s as ShapingStatus);
|
||||||
|
done.push(enabled ? 'деление канала включено' : 'деление канала выключено');
|
||||||
|
}
|
||||||
|
if (torrentDirty) {
|
||||||
|
const s = await apiFetch('PUT', '/api/torrent', token, { enabled: tEnabled });
|
||||||
|
fillTorrent(s as TorrentStatus);
|
||||||
|
done.push(tEnabled ? 'торренты блокируются' : 'блокировка торрентов выключена');
|
||||||
|
}
|
||||||
|
if (done.length) showMsg(done.join(', ').replace(/^./, c => c.toUpperCase()));
|
||||||
} catch (e) { setError(errText(e, 'Не удалось сохранить')); }
|
} catch (e) { setError(errText(e, 'Не удалось сохранить')); }
|
||||||
finally { setBusy(false); }
|
finally { setBusy(false); }
|
||||||
};
|
};
|
||||||
@@ -70,7 +101,8 @@ export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
|
|||||||
return (
|
return (
|
||||||
<div className="node-scrim" onClick={onClose}>
|
<div className="node-scrim" onClick={onClose}>
|
||||||
<div className="node-modal" role="dialog" aria-modal="true" aria-label="Настройки сервера" onClick={e => e.stopPropagation()}>
|
<div className="node-modal" role="dialog" aria-modal="true" aria-label="Настройки сервера" onClick={e => e.stopPropagation()}>
|
||||||
<h3 className="node-modal-title">Деление канала: {node.name}</h3>
|
<h3 className="node-modal-title">Настройки сервера: {node.name}</h3>
|
||||||
|
<h4 className="ss-section">Деление канала</h4>
|
||||||
|
|
||||||
{unsupported ? (
|
{unsupported ? (
|
||||||
<p className="node-modal-hint">
|
<p className="node-modal-hint">
|
||||||
@@ -135,11 +167,65 @@ export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<hr className="ss-divider" />
|
||||||
|
<h4 className="ss-section">Торренты</h4>
|
||||||
|
{tUnsupported ? (
|
||||||
|
<p className="node-modal-hint">
|
||||||
|
Этот сервер не умеет блокировать торренты: обновите его (install.sh → «Обновить»).
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<label className="ss-setting">
|
||||||
|
<span className="ss-setting-text">
|
||||||
|
<span className="ss-setting-name">Блокировать BitTorrent</span>
|
||||||
|
<span className="node-modal-hint">
|
||||||
|
Режет поиск пиров (DHT), трекеры и начало торрент-соединений — как блокировка в 3x-ui.
|
||||||
|
Клиент с шифрованием и HTTPS-трекерами может частично её обойти, но без DHT и открытых
|
||||||
|
трекеров большинство торрентов не стартуют. Остальной трафик не режется.
|
||||||
|
</span>
|
||||||
|
<span className="node-modal-hint">
|
||||||
|
{enabled
|
||||||
|
? 'Кто пытается качать, на 10 минут после последней попытки уходит в фоновый приоритет: уступает остальным, когда канал занят.'
|
||||||
|
: 'Включите деление канала — тогда кто пытается качать, на 10 минут уйдёт ещё и в фоновый приоритет.'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<input type="checkbox" checked={tEnabled} disabled={!tst || busy} onChange={e => setTEnabled(e.target.checked)} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{tst?.settings.enabled && (
|
||||||
|
<div className="ss-status">
|
||||||
|
<span className={`chip ${tst.active ? 'chip--online' : 'chip--offline'}`}>
|
||||||
|
<span className="chip-dot" />{tst.active ? 'работает' : 'не применено'}
|
||||||
|
</span>
|
||||||
|
<span className="ss-mono">заблокировано попыток: {tst.hits.toLocaleString('ru-RU')}</span>
|
||||||
|
{tst.active && !tst.tcp && (
|
||||||
|
<span className="node-modal-hint">
|
||||||
|
TCP не проверяется: на сервере старый nftables. DHT, трекеры и uTP блокируются.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{tst.error && <span className="node-modal-hint">{tst.error}</span>}
|
||||||
|
{tst.seen.length > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="node-modal-hint">Пытались качать за последние 10 минут:</span>
|
||||||
|
<span className="ss-seen">
|
||||||
|
{tst.seen.map(p => (
|
||||||
|
<span key={p.ip} className="chip chip--error">
|
||||||
|
<span className="chip-dot" />{p.name ? `${p.name} · ${p.ip}` : p.ip}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && <p className="login-error">{error}</p>}
|
{error && <p className="login-error">{error}</p>}
|
||||||
|
|
||||||
<div className="node-modal-actions">
|
<div className="node-modal-actions">
|
||||||
{!unsupported && (
|
{!(unsupported && tUnsupported) && (
|
||||||
<button className="btn btn--primary" onClick={save} disabled={!st || busy || !valid}>
|
<button className="btn btn--primary" onClick={save} disabled={busy || !valid || !(shapingDirty || torrentDirty)}>
|
||||||
Сохранить
|
Сохранить
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Copyright (c) 2026 Ivan Vasilev
|
||||||
|
// This source code is licensed under the MIT license found in the
|
||||||
|
// LICENSE file in the root directory of this source tree.
|
||||||
|
|
||||||
|
// Пометка «торренты»: устройство за последние 10 минут пыталось открыть торрент-соединение, и оно
|
||||||
|
// было заблокировано. Без флага (блокировка выключена или awg-ctrl старше) чип не показываем.
|
||||||
|
export default function TorrentChip({ torrent }: { torrent?: boolean }) {
|
||||||
|
if (!torrent) return null;
|
||||||
|
return (
|
||||||
|
<span className="tip-wrap" data-tip="За последние 10 минут пыталось качать торренты: соединения заблокированы, а при делении канала устройство в фоновом приоритете">
|
||||||
|
<span className="chip chip--error"><span className="chip-dot" />торренты</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ export interface User {
|
|||||||
rx?: number;
|
rx?: number;
|
||||||
tx?: number;
|
tx?: number;
|
||||||
priority?: Priority; // нет у awg-ctrl старше деления канала
|
priority?: Priority; // нет у awg-ctrl старше деления канала
|
||||||
|
torrent?: boolean; // пытался качать торренты за последние 10 минут (при включённой блокировке)
|
||||||
}
|
}
|
||||||
export interface ServerInfo {
|
export interface ServerInfo {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -79,6 +80,16 @@ export interface ShapingStatus {
|
|||||||
rateUp: number;
|
rateUp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Блокировка BitTorrent (GET/PUT /api/torrent на awg-ctrl сервера).
|
||||||
|
export interface TorrentStatus {
|
||||||
|
settings: { enabled: boolean };
|
||||||
|
active: boolean; // nft-таблица реально стоит
|
||||||
|
tcp: boolean; // проверяется и TCP-рукопожатие (нужен свежий nftables)
|
||||||
|
hits: number; // заблокированных попыток с последнего применения
|
||||||
|
seen: { ip: string; name: string }[]; // кто пытался за последние 10 минут
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Сравнение версий вида 0.3.3 (хвост после «-» не учитывается): <0 — a старше b.
|
// Сравнение версий вида 0.3.3 (хвост после «-» не учитывается): <0 — a старше b.
|
||||||
export function cmpVersion(a: string, b: string): number {
|
export function cmpVersion(a: string, b: string): number {
|
||||||
const pa = a.split('-')[0].split('.').map(Number), pb = b.split('-')[0].split('.').map(Number);
|
const pa = a.split('-')[0].split('.').map(Number), pb = b.split('-')[0].split('.').map(Number);
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
// LICENSE file in the root directory of this source tree.
|
// LICENSE file in the root directory of this source tree.
|
||||||
import { Fragment, useState, useEffect, useCallback } from 'react';
|
import { Fragment, useState, useEffect, useCallback } from 'react';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
import { apiFetch, copyText, bytes, timeAgo, type NodeInfo, type PageProps, type Priority } from '../../lib/shared';
|
import { apiFetch, copyText, bytes, timeAgo, getServerId, type NodeInfo, type PageProps, type Priority } from '../../lib/shared';
|
||||||
import { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings } from '../../components/icons';
|
import { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings, IcoGlobe } from '../../components/icons';
|
||||||
import ServerPicker, { serverName } from '../../components/ServerPicker';
|
import ServerPicker, { serverName } from '../../components/ServerPicker';
|
||||||
import PriorityChip from '../../components/PriorityChip';
|
import PriorityChip from '../../components/PriorityChip';
|
||||||
|
import TorrentChip from '../../components/TorrentChip';
|
||||||
import './masterkeys.css';
|
import './masterkeys.css';
|
||||||
|
|
||||||
// Вкладка «Мастер-ключи»: sen://-подписки для SenAWG. Мастер-ключ — это ссылка
|
// Вкладка «Мастер-ключи»: sen://-подписки для SenAWG. Мастер-ключ — это ссылка
|
||||||
@@ -16,8 +17,9 @@ import './masterkeys.css';
|
|||||||
interface MasterKey { id: number; uuid: string; label: string; device_limit: number; devices: number; servers: number[]; created_at: number; deleting?: number[] }
|
interface MasterKey { id: number; uuid: string; label: string; device_limit: number; devices: number; servers: number[]; created_at: number; deleting?: number[] }
|
||||||
interface Device {
|
interface Device {
|
||||||
id: number; device_id: string; device_name: string; platform: string; version: string; created_at: number; last_seen: number | null;
|
id: number; device_id: string; device_name: string; platform: string; version: string; created_at: number; last_seen: number | null;
|
||||||
rekey_requested: boolean; online: boolean; lastHandshake: number; rx: number; tx: number; priority?: Priority;
|
rekey_requested: boolean; online: boolean; lastHandshake: number; rx: number; tx: number; priority?: Priority; torrent?: boolean;
|
||||||
servers_total: number; servers_ok: number; // на скольких серверах ключа уже стоит его текущий ключ
|
servers_total: number; servers_ok: number; // на скольких серверах ключа уже стоит его текущий ключ
|
||||||
|
ips?: { server_id: number; ip: string }[]; // IP на каждом сервере ключа (старая панель не отдаёт)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Устройство ещё не на всех серверах ключа (нода не в сети при регистрации или смене ключа) —
|
// Устройство ещё не на всех серверах ключа (нода не в сети при регистрации или смене ключа) —
|
||||||
@@ -31,6 +33,18 @@ const platformLabel = (d: Device): string =>
|
|||||||
|
|
||||||
const errText = (e: any): string => e?.response?.data?.error ?? 'Ошибка запроса';
|
const errText = (e: any): string => e?.response?.data?.error ?? 'Ошибка запроса';
|
||||||
|
|
||||||
|
// Статус — как у пользователей: «онлайн», иначе сколько прошло с последнего handshake.
|
||||||
|
const statusChip = (d: Device) => (
|
||||||
|
<span className={`chip chip--${d.online ? 'online' : 'offline'}`}>
|
||||||
|
<span className="chip-dot" />
|
||||||
|
{d.online ? 'онлайн' : d.lastHandshake ? timeAgo(d.lastHandshake) + ' назад' : 'никогда'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
// IP устройства на выбранном сервере (на каждом сервере ключа он свой), иначе — на первом.
|
||||||
|
const ipOf = (d: Device): string =>
|
||||||
|
(d.ips?.find(x => x.server_id === getServerId()) ?? d.ips?.[0])?.ip ?? '—';
|
||||||
|
|
||||||
export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||||
const [keys, setKeys] = useState<MasterKey[]>([]);
|
const [keys, setKeys] = useState<MasterKey[]>([]);
|
||||||
const [enabled, setEnabled] = useState(true);
|
const [enabled, setEnabled] = useState(true);
|
||||||
@@ -253,17 +267,51 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
// UUID ключа в строке списка — как ID устройств под ним (та же ширина и обрезка),
|
// Метка ключа и под ней UUID — коротко, чтобы не раздувать колонку «Имя». Полностью — в подсказке,
|
||||||
// полностью — в подсказке, по клику копируется.
|
// по клику копируется.
|
||||||
const uuidCell = (k: MasterKey) => (
|
const nameCell = (k: MasterKey) => (
|
||||||
<span className="tip-wrap mk-uuid-wrap" data-tip={`UUID ${k.uuid} · нажми, чтобы скопировать`}>
|
<span className="name-stack">
|
||||||
<button className="mk-uuid-btn" aria-label={`Скопировать UUID ${k.uuid}`}
|
{k.label}
|
||||||
onClick={() => copyText(k.uuid).then(() => showMsg('UUID скопирован'), () => showMsg('Не удалось скопировать'))}>
|
<span className="tip-wrap" data-tip={`UUID ${k.uuid} · нажми, чтобы скопировать`}>
|
||||||
<code className="mk-device-id">{k.uuid}</code>
|
<button className="copy-id" aria-label={`Скопировать UUID ${k.uuid}`}
|
||||||
</button>
|
onClick={() => copyText(k.uuid).then(() => showMsg('UUID скопирован'), () => showMsg('Не удалось скопировать'))}>
|
||||||
|
{k.uuid.slice(0, 8)}…
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Сводка по устройствам ключа в его строке — видна и у свёрнутого ключа.
|
||||||
|
const summary = (list: Device[]) => {
|
||||||
|
if (!list.length) return <><td></td><td></td><td colSpan={2}></td><td></td><td></td></>;
|
||||||
|
const on = list.filter(d => d.online).length;
|
||||||
|
const last = Math.max(...list.map(d => d.lastHandshake || 0));
|
||||||
|
const torrent = list.some(d => d.torrent);
|
||||||
|
const prio = list.some(d => d.priority === 'background') ? 'background' : list.find(d => d.priority)?.priority;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<td>
|
||||||
|
<span className={`chip chip--${on ? 'online' : 'offline'}`}>
|
||||||
|
<span className="chip-dot" />
|
||||||
|
{on ? `онлайн: ${on} из ${list.length}` : last ? timeAgo(last) + ' назад' : 'никогда'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td><span className="user-chips"><PriorityChip priority={prio} torrent={torrent} /><TorrentChip torrent={torrent} /></span></td>
|
||||||
|
<td colSpan={2}></td>
|
||||||
|
<td>{bytes(list.reduce((a, d) => a + d.rx, 0))}</td>
|
||||||
|
<td>{bytes(list.reduce((a, d) => a + d.tx, 0))}</td>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ipCell = (d: Device) => {
|
||||||
|
const all = d.ips ?? [];
|
||||||
|
const ip = <code className="mk-ip">{ipOf(d)}</code>;
|
||||||
|
return all.length > 1
|
||||||
|
? <span className="tip-wrap" data-tip={all.map(x => `${serverName(nodes, x.server_id)}: ${x.ip}`).join(' · ')}>{ip}</span>
|
||||||
|
: ip;
|
||||||
|
};
|
||||||
|
|
||||||
const caret = (k: MasterKey) => (
|
const caret = (k: MasterKey) => (
|
||||||
<button className={`mk-caret${folded.has(k.id) ? ' mk-caret--folded' : ''}`}
|
<button className={`mk-caret${folded.has(k.id) ? ' mk-caret--folded' : ''}`}
|
||||||
aria-label={folded.has(k.id) ? 'Развернуть' : 'Свернуть'} aria-expanded={!folded.has(k.id)}
|
aria-label={folded.has(k.id) ? 'Развернуть' : 'Свернуть'} aria-expanded={!folded.has(k.id)}
|
||||||
@@ -278,13 +326,17 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
|||||||
<span className="mk-device-name">{d.device_name || 'Устройство'}</span>
|
<span className="mk-device-name">{d.device_name || 'Устройство'}</span>
|
||||||
<code className="mk-device-id" title={`ID устройства: ${d.device_id}`}>{d.device_id}</code>
|
<code className="mk-device-id" title={`ID устройства: ${d.device_id}`}>{d.device_id}</code>
|
||||||
</span>
|
</span>
|
||||||
<span className="mk-device-sub">{platformLabel(d)} · был {d.last_seen ? timeAgo(d.last_seen) + ' назад' : 'никогда'}
|
<span className="mk-device-sub">{platformLabel(d)}
|
||||||
{d.rekey_requested ? ' · ждёт смены ключа' : ''}{syncLabel(d)}</span>
|
{d.rekey_requested ? ' · ждёт смены ключа' : ''}{syncLabel(d)}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className={`chip chip--${d.online ? 'online' : 'offline'}`}>
|
{statusChip(d)}
|
||||||
<span className="chip-dot" />{d.online ? 'Онлайн' : 'Офлайн'}
|
{/* как строка IP в карточке пользователя: адрес и чипы приоритета/торрентов */}
|
||||||
</span>
|
<div className="mk-device-ip">
|
||||||
<PriorityChip priority={d.priority} />
|
<IcoGlobe />
|
||||||
|
<span className="mk-ip">{ipOf(d)}</span>
|
||||||
|
<PriorityChip priority={d.priority} torrent={d.torrent} />
|
||||||
|
<TorrentChip torrent={d.torrent} />
|
||||||
|
</div>
|
||||||
<div className="mk-device-traffic">
|
<div className="mk-device-traffic">
|
||||||
<span className="mk-metric"><i className="mk-metric-name" data-sfx=" rx">↓</i><b>{bytes(d.rx)}</b></span>
|
<span className="mk-metric"><i className="mk-metric-name" data-sfx=" rx">↓</i><b>{bytes(d.rx)}</b></span>
|
||||||
<span className="mk-metric"><i className="mk-metric-name" data-sfx=" tx">↑</i><b>{bytes(d.tx)}</b></span>
|
<span className="mk-metric"><i className="mk-metric-name" data-sfx=" tx">↑</i><b>{bytes(d.tx)}</b></span>
|
||||||
@@ -318,8 +370,8 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
|||||||
<table className="mk-table">
|
<table className="mk-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Имя</th><th>ID устройства</th><th>Платформа</th><th>Статус</th>
|
<th>Имя</th><th>Статус</th><th>Приоритет</th><th>IP</th><th>Платформа</th>
|
||||||
<th>↓ rx</th><th>↑ tx</th><th>Был</th><th>Устройства</th><th></th>
|
<th>↓ rx</th><th>↑ tx</th><th>Устройства</th><th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -328,9 +380,8 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
|||||||
) : keys.map(k => {
|
) : keys.map(k => {
|
||||||
if (k.deleting) return (
|
if (k.deleting) return (
|
||||||
<tr key={k.id} className="mk-tr-master mk-last">
|
<tr key={k.id} className="mk-tr-master mk-last">
|
||||||
<td><span className="mk-name"><IcoKey />{k.label}</span></td>
|
<td><span className="mk-name"><IcoKey />{nameCell(k)}</span></td>
|
||||||
<td className="td-mono">{uuidCell(k)}</td>
|
<td colSpan={8}>{deletingChip(k)}</td>
|
||||||
<td colSpan={7}>{deletingChip(k)}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
const list = devs[k.id] ?? [];
|
const list = devs[k.id] ?? [];
|
||||||
@@ -339,28 +390,35 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
|||||||
return (
|
return (
|
||||||
<Fragment key={k.id}>
|
<Fragment key={k.id}>
|
||||||
<tr className="mk-tr-master">
|
<tr className="mk-tr-master">
|
||||||
<td><span className="mk-name">{caret(k)}<IcoKey />{k.label}</span></td>
|
<td><span className="mk-name">{caret(k)}<IcoKey />{nameCell(k)}</span></td>
|
||||||
<td className="td-mono">{uuidCell(k)}</td>
|
{summary(list)}
|
||||||
<td colSpan={5}></td>
|
|
||||||
<td>{meter(k, list.length)}</td>
|
<td>{meter(k, list.length)}</td>
|
||||||
<td className="td-actions mk-td-actions">{actions(k)}</td>
|
<td className="td-actions mk-td-actions">{actions(k)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{tools === k.id && <tr className="mk-tr-tools"><td colSpan={9}>{toolbox(k)}</td></tr>}
|
{tools === k.id && <tr className="mk-tr-tools"><td colSpan={9}>{toolbox(k)}</td></tr>}
|
||||||
{open && list.map((d, i) => (
|
{open && list.map((d, i) => (
|
||||||
<tr key={d.id} className={`mk-tr-device${i === list.length - 1 && free === 0 ? ' mk-last' : ''}`}>
|
<tr key={d.id} className={`mk-tr-device${i === list.length - 1 && free === 0 ? ' mk-last' : ''}`}>
|
||||||
<td><span className="mk-indent">{d.device_name || 'Устройство'}</span></td>
|
|
||||||
<td className="td-mono"><code className="mk-device-id" title={d.device_id}>{d.device_id}</code></td>
|
|
||||||
<td>{platformLabel(d)}</td>
|
|
||||||
<td>
|
<td>
|
||||||
<span className={`chip chip--${d.online ? 'online' : 'offline'}`}>
|
<span className="mk-indent">
|
||||||
<span className="chip-dot" />{d.online ? 'Онлайн' : 'Офлайн'}
|
<span className="name-stack">
|
||||||
|
{d.device_name || 'Устройство'}
|
||||||
|
<span className="tip-wrap" data-tip={`ID устройства ${d.device_id} · нажми, чтобы скопировать`}>
|
||||||
|
<button className="copy-id" aria-label={`Скопировать ID устройства ${d.device_id}`}
|
||||||
|
onClick={() => copyText(d.device_id).then(() => showMsg('ID устройства скопирован'), () => showMsg('Не удалось скопировать'))}>
|
||||||
|
{d.device_id.slice(0, 8)}…
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
|
||||||
<td className="td-mono">{bytes(d.rx)}</td>
|
|
||||||
<td className="td-mono">{bytes(d.tx)}</td>
|
|
||||||
<td>{d.last_seen ? timeAgo(d.last_seen) + ' назад' : 'никогда'}
|
|
||||||
{d.rekey_requested && <span className="mk-flag"> · ждёт смены ключа</span>}
|
{d.rekey_requested && <span className="mk-flag"> · ждёт смены ключа</span>}
|
||||||
{syncLabel(d) && <span className="mk-flag">{syncLabel(d)}</span>}</td>
|
{syncLabel(d) && <span className="mk-flag">{syncLabel(d)}</span>}
|
||||||
|
</td>
|
||||||
|
<td>{statusChip(d)}</td>
|
||||||
|
<td><span className="user-chips"><PriorityChip priority={d.priority} torrent={d.torrent} /><TorrentChip torrent={d.torrent} /></span></td>
|
||||||
|
<td className="td-mono">{ipCell(d)}</td>
|
||||||
|
<td>{platformLabel(d)}</td>
|
||||||
|
<td>{bytes(d.rx)}</td>
|
||||||
|
<td>{bytes(d.tx)}</td>
|
||||||
<td></td>
|
<td></td>
|
||||||
<td className="td-actions mk-td-actions">{deviceActions(d)}</td>
|
<td className="td-actions mk-td-actions">{deviceActions(d)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -26,13 +26,6 @@ LICENSE file in the root directory of this source tree.
|
|||||||
.mk-tr-master td { background: var(--surface-container); font-weight: 500; padding-top: 6px; padding-bottom: 6px; }
|
.mk-tr-master td { background: var(--surface-container); font-weight: 500; padding-top: 6px; padding-bottom: 6px; }
|
||||||
.mk-tr-master:hover td { background: var(--surface-container); }
|
.mk-tr-master:hover td { background: var(--surface-container); }
|
||||||
.mk-name { display: inline-flex; align-items: center; gap: 8px; color: var(--primary); }
|
.mk-name { display: inline-flex; align-items: center; gap: 8px; color: var(--primary); }
|
||||||
/* UUID ключа в строке: выглядит как ID устройства, по клику копирует */
|
|
||||||
.mk-uuid-btn {
|
|
||||||
background: none; border: 0; padding: 0; margin: 0;
|
|
||||||
cursor: pointer; font: inherit; font-weight: 400; color: inherit; line-height: inherit;
|
|
||||||
}
|
|
||||||
.mk-uuid-btn:hover .mk-device-id { color: var(--primary); }
|
|
||||||
.mk-uuid-btn:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; border-radius: 4px; }
|
|
||||||
.mk-master-uuid {
|
.mk-master-uuid {
|
||||||
display: block;
|
display: block;
|
||||||
font-family: var(--mono); font-size: 12px; font-weight: 400;
|
font-family: var(--mono); font-size: 12px; font-weight: 400;
|
||||||
@@ -46,6 +39,9 @@ LICENSE file in the root directory of this source tree.
|
|||||||
.mk-tr-tools:hover { background: transparent; }
|
.mk-tr-tools:hover { background: transparent; }
|
||||||
.mk-tr-device td, .mk-tr-free td { padding-top: 10px; padding-bottom: 10px; }
|
.mk-tr-device td, .mk-tr-free td { padding-top: 10px; padding-bottom: 10px; }
|
||||||
.mk-flag { color: var(--neutral-text); font-size: 12px; }
|
.mk-flag { color: var(--neutral-text); font-size: 12px; }
|
||||||
|
/* чипы приоритета и торрентов в одной ячейке — как во вкладке «Пользователи» */
|
||||||
|
.mk-table .user-chips { display: inline-flex; flex-wrap: nowrap; gap: 6px; }
|
||||||
|
.mk-ip { font-family: var(--mono); }
|
||||||
/* иерархия: линия слева от имени и «ножка» к каждой строке; у последней линия обрывается */
|
/* иерархия: линия слева от имени и «ножка» к каждой строке; у последней линия обрывается */
|
||||||
.mk-tr-device td:first-child, .mk-tr-free td:first-child {
|
.mk-tr-device td:first-child, .mk-tr-free td:first-child {
|
||||||
background: linear-gradient(var(--outline), var(--outline)) 30px 0 / 2px 100% no-repeat;
|
background: linear-gradient(var(--outline), var(--outline)) 30px 0 / 2px 100% no-repeat;
|
||||||
@@ -210,6 +206,13 @@ LICENSE file in the root directory of this source tree.
|
|||||||
}
|
}
|
||||||
.mk-device-name { font-size: 14px; font-weight: 500; color: var(--on-surface); }
|
.mk-device-name { font-size: 14px; font-weight: 500; color: var(--on-surface); }
|
||||||
.mk-device-sub { font-size: 12px; color: var(--neutral-text); }
|
.mk-device-sub { font-size: 12px; color: var(--neutral-text); }
|
||||||
|
.mk-device-ip {
|
||||||
|
display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
color: var(--neutral-text);
|
||||||
|
}
|
||||||
|
.mk-device-ip .mk-ip { font-size: 13px; }
|
||||||
|
.mk-device-ip .tip-wrap { width: auto; }
|
||||||
.mk-device-traffic { display: flex; gap: 12px; font-family: var(--mono); font-size: 12px; color: var(--neutral-text); }
|
.mk-device-traffic { display: flex; gap: 12px; font-family: var(--mono); font-size: 12px; color: var(--neutral-text); }
|
||||||
.mk-metric { display: inline-flex; gap: 4px; }
|
.mk-metric { display: inline-flex; gap: 4px; }
|
||||||
.mk-metric i, .mk-metric b { font-style: normal; font-weight: 400; }
|
.mk-metric i, .mk-metric b { font-style: normal; font-weight: 400; }
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from '../../lib/shared';
|
} from '../../lib/shared';
|
||||||
import { IcoPlus, IcoRefresh, IcoQR, IcoTrash, IcoGlobe } from '../../components/icons';
|
import { IcoPlus, IcoRefresh, IcoQR, IcoTrash, IcoGlobe } from '../../components/icons';
|
||||||
import PriorityChip from '../../components/PriorityChip';
|
import PriorityChip from '../../components/PriorityChip';
|
||||||
|
import TorrentChip from '../../components/TorrentChip';
|
||||||
import './users.css';
|
import './users.css';
|
||||||
|
|
||||||
// Вкладка «Пользователи»: форма создания + таблица (десктоп) / карточки (мобильный)
|
// Вкладка «Пользователи»: форма создания + таблица (десктоп) / карточки (мобильный)
|
||||||
@@ -29,7 +30,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
|||||||
const data = await apiFetch('GET', '/api/users/stats', tok);
|
const data = await apiFetch('GET', '/api/users/stats', tok);
|
||||||
setUsers(prev => prev.map(u => {
|
setUsers(prev => prev.map(u => {
|
||||||
const s = (data.users as User[] ?? []).find(x => x.name === u.name);
|
const s = (data.users as User[] ?? []).find(x => x.name === u.name);
|
||||||
return s ? { ...u, online: s.online, lastHandshake: s.lastHandshake, rx: s.rx, tx: s.tx, priority: s.priority } : u;
|
return s ? { ...u, online: s.online, lastHandshake: s.lastHandshake, rx: s.rx, tx: s.tx, priority: s.priority, torrent: s.torrent } : u;
|
||||||
}));
|
}));
|
||||||
} catch { /* silent */ }
|
} catch { /* silent */ }
|
||||||
}, []);
|
}, []);
|
||||||
@@ -187,7 +188,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
|||||||
: 'никогда'}
|
: 'никогда'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td><PriorityChip priority={u.priority} /></td>
|
<td><span className="user-chips"><PriorityChip priority={u.priority} torrent={u.torrent} /><TorrentChip torrent={u.torrent} /></span></td>
|
||||||
<td>
|
<td>
|
||||||
{u.name}
|
{u.name}
|
||||||
{u.key_gen !== serverGen && (
|
{u.key_gen !== serverGen && (
|
||||||
@@ -254,7 +255,8 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
|||||||
<div className="user-card-ip">
|
<div className="user-card-ip">
|
||||||
<IcoGlobe />
|
<IcoGlobe />
|
||||||
<span className="user-card-ip-text">{u.ip}</span>
|
<span className="user-card-ip-text">{u.ip}</span>
|
||||||
<PriorityChip priority={u.priority} />
|
<PriorityChip priority={u.priority} torrent={u.torrent} />
|
||||||
|
<TorrentChip torrent={u.torrent} />
|
||||||
</div>
|
</div>
|
||||||
<div className="user-card-metrics">
|
<div className="user-card-metrics">
|
||||||
<div className="metric-block">
|
<div className="metric-block">
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ LICENSE file in the root directory of this source tree.
|
|||||||
}
|
}
|
||||||
.user-card-ip {
|
.user-card-ip {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
color: var(--neutral-text);
|
color: var(--neutral-text);
|
||||||
@@ -78,3 +79,6 @@ LICENSE file in the root directory of this source tree.
|
|||||||
/* .tip-wrap на мобильной растягивается на всю ширину — метке это не нужно. */
|
/* .tip-wrap на мобильной растягивается на всю ширину — метке это не нужно. */
|
||||||
.user-gen { width: auto; }
|
.user-gen { width: auto; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Чипы приоритета и торрентов в одной ячейке */
|
||||||
|
.user-chips { display: inline-flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "awg-cli",
|
"name": "awg-cli",
|
||||||
"version": "0.4.1",
|
"version": "0.4.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tsx": "^4.7.0",
|
"tsx": "^4.7.0",
|
||||||
|
|||||||
+1
-1
@@ -100,7 +100,7 @@ trap 'exec >&- 2>&-; wait "$LOG_PID" 2>/dev/null || true' EXIT
|
|||||||
# ⚠️ Дублирует .env из репозитория: установщик собирает URL релиза и печатает
|
# ⚠️ Дублирует .env из репозитория: установщик собирает URL релиза и печатает
|
||||||
# баннер ДО того, как архив с .env скачан, поэтому взять их оттуда не может.
|
# баннер ДО того, как архив с .env скачан, поэтому взять их оттуда не может.
|
||||||
# При бампе версии правь оба места.
|
# При бампе версии правь оба места.
|
||||||
VERSION="0.4.1"
|
VERSION="0.4.3"
|
||||||
CHANNEL="${CHANNEL:-Beta}"
|
CHANNEL="${CHANNEL:-Beta}"
|
||||||
|
|
||||||
# Откуда брать архив и как называется продукт. По умолчанию — релизы на GitHub; источник
|
# Откуда брать архив и как называется продукт. По умолчанию — релизы на GitHub; источник
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ type metricsPeer struct {
|
|||||||
Rx float64 `json:"rx"`
|
Rx float64 `json:"rx"`
|
||||||
Tx float64 `json:"tx"`
|
Tx float64 `json:"tx"`
|
||||||
Priority string `json:"priority"`
|
Priority string `json:"priority"`
|
||||||
|
Torrent bool `json:"torrent"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type metricsHost struct {
|
type metricsHost struct {
|
||||||
@@ -65,6 +66,7 @@ type metricsBody struct {
|
|||||||
TxBytes float64 `json:"tx_bytes"`
|
TxBytes float64 `json:"tx_bytes"`
|
||||||
Link linkStats `json:"link"`
|
Link linkStats `json:"link"`
|
||||||
Shaping ShapingStatus `json:"shaping"`
|
Shaping ShapingStatus `json:"shaping"`
|
||||||
|
Torrent TorrentStatus `json:"torrent"`
|
||||||
Host *metricsHost `json:"host"`
|
Host *metricsHost `json:"host"`
|
||||||
List []metricsPeer `json:"list"`
|
List []metricsPeer `json:"list"`
|
||||||
}
|
}
|
||||||
@@ -127,6 +129,7 @@ func (s *Server) collectMetrics() (*metricsBody, error) {
|
|||||||
if st.Up {
|
if st.Up {
|
||||||
stats = s.getPeersData()
|
stats = s.getPeersData()
|
||||||
}
|
}
|
||||||
|
torrent := s.torrent.SeenSet() // до запроса: внутри цикла по строкам — ни базы, ни nft
|
||||||
rows, err := s.db.Query("SELECT name, ip, pub_key FROM users WHERE vpn_key != ''")
|
rows, err := s.db.Query("SELECT name, ip, pub_key FROM users WHERE vpn_key != ''")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -139,7 +142,7 @@ func (s *Server) collectMetrics() (*metricsBody, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
ps := stats[pub]
|
ps := stats[pub]
|
||||||
b.List = append(b.List, metricsPeer{"user", n, ip, ps.Online, orZero(ps.Rx), orZero(ps.Tx), s.shaping.PriorityOf(ip)})
|
b.List = append(b.List, metricsPeer{"user", n, ip, ps.Online, orZero(ps.Rx), orZero(ps.Tx), s.shaping.PriorityOf(ip), torrent[ip]})
|
||||||
}
|
}
|
||||||
rows.Close()
|
rows.Close()
|
||||||
devs, err := s.peersAll()
|
devs, err := s.peersAll()
|
||||||
@@ -148,7 +151,7 @@ func (s *Server) collectMetrics() (*metricsBody, error) {
|
|||||||
}
|
}
|
||||||
for _, p := range devs {
|
for _, p := range devs {
|
||||||
ps := stats[p.PubKey]
|
ps := stats[p.PubKey]
|
||||||
b.List = append(b.List, metricsPeer{"device", "", p.IP, ps.Online, orZero(ps.Rx), orZero(ps.Tx), s.shaping.PriorityOf(p.IP)})
|
b.List = append(b.List, metricsPeer{"device", "", p.IP, ps.Online, orZero(ps.Rx), orZero(ps.Tx), s.shaping.PriorityOf(p.IP), torrent[p.IP]})
|
||||||
}
|
}
|
||||||
b.Peers.Total = st.Peers
|
b.Peers.Total = st.Peers
|
||||||
for _, ps := range stats {
|
for _, ps := range stats {
|
||||||
@@ -163,6 +166,7 @@ func (s *Server) collectMetrics() (*metricsBody, error) {
|
|||||||
s.linkCounter("rx_dropped"), s.linkCounter("tx_dropped"), s.linkCounter("rx_errors"), s.linkCounter("tx_errors"),
|
s.linkCounter("rx_dropped"), s.linkCounter("tx_dropped"), s.linkCounter("rx_errors"), s.linkCounter("tx_errors"),
|
||||||
}
|
}
|
||||||
b.Shaping = s.shaping.Status()
|
b.Shaping = s.shaping.Status()
|
||||||
|
b.Torrent = s.torrent.Status()
|
||||||
b.Host = readHost()
|
b.Host = readHost()
|
||||||
return b, nil
|
return b, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package ctrl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/maeneko/forgetting/internal/logx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SQLite открыт с одним соединением: любой запрос в базу изнутри цикла по строкам users ждал
|
||||||
|
// бы его вечно и намертво вешал awg-ctrl (так было в 0.4.2 с меткой торрента). Скрейп метрик
|
||||||
|
// идёт раз в 15 с, поэтому зависание проявлялось сразу.
|
||||||
|
func TestCollectMetricsNoDeadlock(t *testing.T) {
|
||||||
|
db, err := openDB(filepath.Join(t.TempDir(), "users.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
db.Exec("INSERT INTO users (name, ip, pub_key, vpn_key) VALUES (?, ?, ?, 'vpn://x')", fmt.Sprintf("u%d", i), fmt.Sprintf("10.9.0.%d", i+2), fmt.Sprintf("pk%d", i))
|
||||||
|
}
|
||||||
|
s := &Server{db: db, iface: "awg1", log: logx.New(os.Stderr, t.TempDir())}
|
||||||
|
s.shaping = newShaping(s)
|
||||||
|
s.torrent = newTorrent(s)
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { _, err := s.collectMetrics(); done <- err }()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("collectMetrics завис: запрос в базу изнутри цикла по строкам")
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-2
@@ -73,6 +73,7 @@ func (s *Server) routes() []route {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
R("GET", "/api/users/stats", true, a, func(c *rctx) (result, error) {
|
R("GET", "/api/users/stats", true, a, func(c *rctx) (result, error) {
|
||||||
|
torrent := s.torrent.SeenSet()
|
||||||
rows, err := s.db.Query("SELECT name, ip, pub_key, key_gen FROM users WHERE vpn_key != ''")
|
rows, err := s.db.Query("SELECT name, ip, pub_key, key_gen FROM users WHERE vpn_key != ''")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result{}, err
|
return result{}, err
|
||||||
@@ -87,7 +88,8 @@ func (s *Server) routes() []route {
|
|||||||
}
|
}
|
||||||
ps := stats[pub]
|
ps := stats[pub]
|
||||||
list = append(list, ob{{"name", n}, {"ip", ip}, {"key_gen", kg}, {"online", ps.Online},
|
list = append(list, ob{{"name", n}, {"ip", ip}, {"key_gen", kg}, {"online", ps.Online},
|
||||||
{"lastHandshake", ps.LastHandshake}, {"rx", ps.Rx}, {"tx", ps.Tx}, {"priority", s.shaping.PriorityOf(ip)}})
|
{"lastHandshake", ps.LastHandshake}, {"rx", ps.Rx}, {"tx", ps.Tx}, {"priority", s.shaping.PriorityOf(ip)},
|
||||||
|
{"torrent", torrent[ip]}})
|
||||||
}
|
}
|
||||||
rows.Close()
|
rows.Close()
|
||||||
return ok(ob{{"users", list}})
|
return ok(ob{{"users", list}})
|
||||||
@@ -137,6 +139,7 @@ func (s *Server) routes() []route {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
R("GET", "/api/peers", true, a, func(c *rctx) (result, error) {
|
R("GET", "/api/peers", true, a, func(c *rctx) (result, error) {
|
||||||
|
torrent := s.torrent.SeenSet()
|
||||||
stats := s.getPeersData()
|
stats := s.getPeersData()
|
||||||
peers, err := s.peersAll()
|
peers, err := s.peersAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -147,7 +150,7 @@ func (s *Server) routes() []route {
|
|||||||
ps := stats[p.PubKey]
|
ps := stats[p.PubKey]
|
||||||
list = append(list, ob{{"pub_key", p.PubKey}, {"ip", p.IP}, {"psk_key", p.PskKey}, {"owner", p.Owner},
|
list = append(list, ob{{"pub_key", p.PubKey}, {"ip", p.IP}, {"psk_key", p.PskKey}, {"owner", p.Owner},
|
||||||
{"online", ps.Online}, {"lastHandshake", ps.LastHandshake}, {"rx", ps.Rx}, {"tx", ps.Tx},
|
{"online", ps.Online}, {"lastHandshake", ps.LastHandshake}, {"rx", ps.Rx}, {"tx", ps.Tx},
|
||||||
{"priority", s.shaping.PriorityOf(p.IP)}})
|
{"priority", s.shaping.PriorityOf(p.IP)}, {"torrent", torrent[p.IP]}})
|
||||||
}
|
}
|
||||||
return ok(ob{{"peers", list}})
|
return ok(ob{{"peers", list}})
|
||||||
}),
|
}),
|
||||||
@@ -357,6 +360,21 @@ func (s *Server) routes() []route {
|
|||||||
return ok(s.shaping.Status())
|
return ok(s.shaping.Status())
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Блокировка BitTorrent. Тоже под /api/ и через PUT — чтобы проходила через awg-agent на ноде.
|
||||||
|
R("GET", "/api/torrent", false, a, func(c *rctx) (result, error) { return ok(s.torrent.Status()) }),
|
||||||
|
|
||||||
|
R("PUT", "/api/torrent", false, a, func(c *rctx) (result, error) {
|
||||||
|
enabled, isBool := c.obj().Get2("enabled").(bool)
|
||||||
|
if !isBool {
|
||||||
|
return result{}, httpErr(400, "enabled должен быть true/false")
|
||||||
|
}
|
||||||
|
// nft не нашёлся или ядро отказало: оператору нужна причина, а не 500.
|
||||||
|
if err := s.torrent.Update(TorrentSettings{Enabled: enabled}); err != nil {
|
||||||
|
return result{}, httpErr(500, err.Error())
|
||||||
|
}
|
||||||
|
return ok(s.torrent.Status())
|
||||||
|
}),
|
||||||
|
|
||||||
R("GET", "/api/metrics", false, a, func(c *rctx) (result, error) {
|
R("GET", "/api/metrics", false, a, func(c *rctx) (result, error) {
|
||||||
d, err := s.metricsCached()
|
d, err := s.metricsCached()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ type Server struct {
|
|||||||
c Config
|
c Config
|
||||||
|
|
||||||
shaping *Shaping
|
shaping *Shaping
|
||||||
|
torrent *Torrent
|
||||||
metrics metricsCache
|
metrics metricsCache
|
||||||
monitor monitorJob
|
monitor monitorJob
|
||||||
cliPath string
|
cliPath string
|
||||||
@@ -155,6 +156,7 @@ func New() (*Server, error) {
|
|||||||
}
|
}
|
||||||
s.log.Info("awg params loaded from conf", "Jc", params.Jc, "H1", params.H1, "gen", gen)
|
s.log.Info("awg params loaded from conf", "Jc", params.Jc, "H1", params.H1, "gen", gen)
|
||||||
s.shaping = newShaping(s)
|
s.shaping = newShaping(s)
|
||||||
|
s.torrent = newTorrent(s)
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,5 +184,6 @@ func (s *Server) Run() error {
|
|||||||
// ни один из путей awg-quick up внутри awg-ctrl тогда не срабатывает.
|
// ни один из путей awg-quick up внутри awg-ctrl тогда не срабатывает.
|
||||||
go s.shaping.Apply()
|
go s.shaping.Apply()
|
||||||
s.shaping.Start()
|
s.shaping.Start()
|
||||||
|
go s.torrent.Apply() // nft-таблицы после перезагрузки нет — ставим заново
|
||||||
return <-errc
|
return <-errc
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-26
@@ -20,6 +20,7 @@ import (
|
|||||||
// загрузка (к клиенту) — CAKE root на awg1, dual-dsthost, diffserv3
|
// загрузка (к клиенту) — CAKE root на awg1, dual-dsthost, diffserv3
|
||||||
// отдача (от клиента) — ingress awg1 → ifb-awg, CAKE dual-srchost, besteffort
|
// отдача (от клиента) — ingress awg1 → ifb-awg, CAKE dual-srchost, besteffort
|
||||||
// тяжёлые при перегрузке — DSCP CS1 (bulk-tin CAKE) через отдельную nft-таблицу
|
// тяжёлые при перегрузке — DSCP CS1 (bulk-tin CAKE) через отдельную nft-таблицу
|
||||||
|
// с меткой «торренты» — туда же, пока метка висит (см. torrent.go), независимо от перегрузки
|
||||||
//
|
//
|
||||||
// Ничего не пишет в awg1.conf (PostUp не трогаем): всё применяется из процесса и
|
// Ничего не пишет в awg1.conf (PostUp не трогаем): всё применяется из процесса и
|
||||||
// идемпотентно. Правила tc пропадают вместе с интерфейсом, поэтому Apply зовётся на
|
// идемпотентно. Правила tc пропадают вместе с интерфейсом, поэтому Apply зовётся на
|
||||||
@@ -123,6 +124,7 @@ type Shaping struct {
|
|||||||
histOrder []string
|
histOrder []string
|
||||||
totals []tick
|
totals []tick
|
||||||
heavy *orderedSet
|
heavy *orderedSet
|
||||||
|
applied *orderedSet // что сейчас стоит в nft-наборе: тяжёлые ∪ с меткой торрента
|
||||||
congested bool
|
congested bool
|
||||||
lastRateDn float64
|
lastRateDn float64
|
||||||
lastRateUp float64
|
lastRateUp float64
|
||||||
@@ -138,7 +140,7 @@ func newShaping(s *Server) *Shaping {
|
|||||||
sampleS := envNum("SHAPE_SAMPLE_S", 10)
|
sampleS := envNum("SHAPE_SAMPLE_S", 10)
|
||||||
windowS := envNum("SHAPE_WINDOW_S", 600)
|
windowS := envNum("SHAPE_WINDOW_S", 600)
|
||||||
return &Shaping{
|
return &Shaping{
|
||||||
s: s, prev: map[string][2]float64{}, history: map[string][]tick{}, heavy: newOrderedSet(),
|
s: s, prev: map[string][2]float64{}, history: map[string][]tick{}, heavy: newOrderedSet(), applied: newOrderedSet(),
|
||||||
sampleS: sampleS, windowTicks: int(math.Max(3, math.Round(windowS/sampleS))),
|
sampleS: sampleS, windowTicks: int(math.Max(3, math.Round(windowS/sampleS))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -266,13 +268,12 @@ func (h *Shaping) applyNow() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
h.mu.Lock()
|
heavy := h.desired()
|
||||||
heavy := h.heavy.list()
|
|
||||||
h.mu.Unlock()
|
|
||||||
if err := h.nftApply(heavy); err != nil {
|
if err := h.nftApply(heavy); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
|
h.applied = setOf(heavy)
|
||||||
h.lastError = ""
|
h.lastError = ""
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
h.s.log.Info("shaping applied", "down", st.DownMbit, "up", st.UpMbit, "overhead", oh, "heavy", len(heavy))
|
h.s.log.Info("shaping applied", "down", st.DownMbit, "up", st.UpMbit, "overhead", oh, "heavy", len(heavy))
|
||||||
@@ -287,6 +288,7 @@ func (h *Shaping) removeNow() {
|
|||||||
shExec("nft", "delete", "table", "inet", nftTable)
|
shExec("nft", "delete", "table", "inet", nftTable)
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
h.heavy.clear()
|
h.heavy.clear()
|
||||||
|
h.applied.clear()
|
||||||
h.history, h.histOrder, h.prev = map[string][]tick{}, nil, map[string][2]float64{}
|
h.history, h.histOrder, h.prev = map[string][]tick{}, nil, map[string][2]float64{}
|
||||||
h.totals = nil
|
h.totals = nil
|
||||||
h.congested = false
|
h.congested = false
|
||||||
@@ -319,6 +321,50 @@ func (h *Shaping) Update(st ShapingSettings) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// desired — кого держать в фоновом классе: тяжёлые (порядок сэмплера) и, следом, устройства с
|
||||||
|
// меткой «торренты». Метку ставит и снимает nft (таймаут), поэтому сверяемся каждый тик.
|
||||||
|
func (h *Shaping) desired() []string {
|
||||||
|
h.mu.Lock()
|
||||||
|
out := setOf(h.heavy.list())
|
||||||
|
h.mu.Unlock()
|
||||||
|
for _, ip := range h.s.torrent.SeenList() {
|
||||||
|
out.add(ip)
|
||||||
|
}
|
||||||
|
return out.list()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setOf(list []string) *orderedSet {
|
||||||
|
o := newOrderedSet()
|
||||||
|
for _, k := range list {
|
||||||
|
o.add(k)
|
||||||
|
}
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncNft — привести nft-набор к desired(); без изменений nft не трогаем.
|
||||||
|
func (h *Shaping) syncNft() {
|
||||||
|
want := h.desired()
|
||||||
|
h.mu.Lock()
|
||||||
|
same := len(want) == h.applied.size()
|
||||||
|
for _, ip := range want {
|
||||||
|
same = same && h.applied.has(ip)
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
if same {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.opMu.Lock()
|
||||||
|
err := h.nftApply(want)
|
||||||
|
h.opMu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
h.noteError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.mu.Lock()
|
||||||
|
h.applied = setOf(want)
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
// ───────── сэмплер (RFC 6057) ─────────
|
// ───────── сэмплер (RFC 6057) ─────────
|
||||||
|
|
||||||
func (h *Shaping) pushTick(arr []tick, t tick) []tick {
|
func (h *Shaping) pushTick(arr []tick, t tick) []tick {
|
||||||
@@ -391,6 +437,8 @@ func (h *Shaping) tick() {
|
|||||||
h.tickNo++
|
h.tickNo++
|
||||||
tickNo := h.tickNo
|
tickNo := h.tickNo
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
// В конце любого тика, в том числе пока окно не набралось: метка торрента не ждёт 10 минут.
|
||||||
|
defer h.syncNft()
|
||||||
|
|
||||||
// Страховка: интерфейс могли поднять руками (awg-quick down/up) — qdisc тогда исчез.
|
// Страховка: интерфейс могли поднять руками (awg-quick down/up) — qdisc тогда исчез.
|
||||||
if tickNo%checkEvery == 0 && !h.hasCake(h.s.iface) {
|
if tickNo%checkEvery == 0 && !h.hasCake(h.s.iface) {
|
||||||
@@ -443,18 +491,9 @@ func (h *Shaping) tick() {
|
|||||||
h.lastRateUp = totalUp * 8 / dt / 1e6
|
h.lastRateUp = totalUp * 8 / dt / 1e6
|
||||||
|
|
||||||
if !st.Auto {
|
if !st.Auto {
|
||||||
changed := h.heavy.size() > 0 || h.congested
|
|
||||||
h.heavy.clear()
|
h.heavy.clear()
|
||||||
h.congested = false
|
h.congested = false
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
if changed {
|
|
||||||
h.opMu.Lock()
|
|
||||||
err := h.nftApply(nil)
|
|
||||||
h.opMu.Unlock()
|
|
||||||
if err != nil {
|
|
||||||
h.noteError(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(h.totals) < h.windowTicks { // пока окно не набралось, решений не принимаем
|
if len(h.totals) < h.windowTicks { // пока окно не набралось, решений не принимаем
|
||||||
@@ -507,21 +546,11 @@ func (h *Shaping) tick() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var apply []string
|
|
||||||
if changed || wasCongested != h.congested {
|
if changed || wasCongested != h.congested {
|
||||||
h.heavy = next
|
h.heavy = next
|
||||||
apply = next.list()
|
h.s.log.Info("shaping: класс тяжёлых обновлён", "congested", h.congested, "avgMbit", math.Round(avg), "heavy", next.list())
|
||||||
h.s.log.Info("shaping: класс тяжёлых обновлён", "congested", h.congested, "avgMbit", math.Round(avg), "heavy", apply)
|
|
||||||
}
|
}
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
if changed || wasCongested != h.congested {
|
|
||||||
h.opMu.Lock()
|
|
||||||
err := h.nftApply(apply)
|
|
||||||
h.opMu.Unlock()
|
|
||||||
if err != nil {
|
|
||||||
h.noteError(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Shaping) Start() {
|
func (h *Shaping) Start() {
|
||||||
@@ -571,7 +600,7 @@ func (h *Shaping) qdiscStats(dev string) *QdiscStats {
|
|||||||
func (h *Shaping) PriorityOf(ip string) string {
|
func (h *Shaping) PriorityOf(ip string) string {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
defer h.mu.Unlock()
|
||||||
if h.heavy.has(ip) {
|
if h.applied.has(ip) { // то, что реально стоит в nft: тяжёлые и с меткой торрента
|
||||||
return "background"
|
return "background"
|
||||||
}
|
}
|
||||||
return "normal"
|
return "normal"
|
||||||
@@ -593,7 +622,7 @@ func (h *Shaping) Status() ShapingStatus {
|
|||||||
devices++
|
devices++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
heavy := h.heavy.list()
|
heavy := h.applied.list()
|
||||||
if heavy == nil {
|
if heavy == nil {
|
||||||
heavy = []string{}
|
heavy = []string{}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
package ctrl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Блокировка BitTorrent — то же, что sniffing + blackhole в Xray (3x-ui), только в ядре.
|
||||||
|
//
|
||||||
|
// Решение принимается по первым байтам и один раз на соединение: совпала сигнатура — соединение
|
||||||
|
// получает бит в ct mark и дальше режется целиком, в обе стороны. Смотрим только то, что
|
||||||
|
// клиенты шлют в интернет (iifname awg1), до NAT, поэтому источник — IP клиента в туннеле.
|
||||||
|
//
|
||||||
|
// Ловится: DHT (запросы и ответы), UDP-трекеры, начало uTP-соединения, а при поддержке @ih в
|
||||||
|
// nft/ядре — ещё открытое TCP-рукопожатие и HTTP-анонс. Не ловится шифрованное рукопожатие
|
||||||
|
// (MSE/PE) и HTTPS-трекеры — так же, как у Xray. Но без DHT и трекеров клиенту негде брать пиров.
|
||||||
|
//
|
||||||
|
// Как и шейпинг, ничего не пишет в awg1.conf: таблица ставится из процесса и идемпотентно.
|
||||||
|
// nft-таблица не привязана к интерфейсу (iifname — по имени), поэтому переживает его рестарт;
|
||||||
|
// после перезагрузки сервера её возвращает Apply на старте.
|
||||||
|
|
||||||
|
type TorrentSettings struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TorrentPeer struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Name string `json:"name"` // имя пользователя; у sen://-устройства — пусто
|
||||||
|
}
|
||||||
|
|
||||||
|
type TorrentStatus struct {
|
||||||
|
Settings TorrentSettings `json:"settings"`
|
||||||
|
Active bool `json:"active"` // таблица реально стоит
|
||||||
|
TCP bool `json:"tcp"` // проверяется и TCP (нужен @ih: nftables ≥ 1.0, свежее ядро)
|
||||||
|
Hits float64 `json:"hits"` // заблокированных попыток с последнего применения
|
||||||
|
Seen []TorrentPeer `json:"seen"` // кто пытался за последние torrentSeenTTL
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
torrentTable = "awg_torrent"
|
||||||
|
torrentMark = "0x00100000" // свой бит ct mark: чужие биты не трогаем
|
||||||
|
torrentSeenTTL = "10m"
|
||||||
|
torrentCacheS = 5 * time.Second // /api/users/stats опрашивается часто — nft не дёргаем на каждый
|
||||||
|
)
|
||||||
|
|
||||||
|
// Сигнатуры. @th,64 — начало данных UDP (заголовок 8 байт); @ih,0 — начало данных TCP с учётом опций.
|
||||||
|
const torrentUDPRules = `
|
||||||
|
udp dport 53 return
|
||||||
|
@th,64,96 0x64313a6164323a696432303a jump block
|
||||||
|
@th,64,96 0x64313a7264323a696432303a jump block
|
||||||
|
@th,64,96 0x000004172710198000000000 jump block
|
||||||
|
ct state new udp length >= 28 @th,64,8 0x41 @th,72,8 < 3 @th,128,32 0 jump block`
|
||||||
|
|
||||||
|
// DHT-запрос «d1:ad2:id20:» и ответ «d1:rd2:id20:» (bencode, ключи отсортированы — «a»/«r» первые);
|
||||||
|
// UDP-трекер: protocol_id 0x41727101980 + action connect (0); uTP SYN: тип 4, версия 1 (0x41),
|
||||||
|
// расширение 0–2, timestamp_difference 0 — только в новом потоке, где QUIC начинается с длинного
|
||||||
|
// заголовка (0xC0+), и не на 53-м порту, чтобы случайный ID DNS-запроса не сошёлся.
|
||||||
|
|
||||||
|
const torrentTCPRules = `
|
||||||
|
@ih,0,128 0x13426974546f7272656e742070726f74 jump block
|
||||||
|
@ih,0,112 0x474554202f616e6e6f756e63653f jump block`
|
||||||
|
|
||||||
|
// «\x13BitTorrent prot» — открытое рукопожатие пиров; «GET /announce?» — HTTP-трекер.
|
||||||
|
|
||||||
|
type Torrent struct {
|
||||||
|
s *Server
|
||||||
|
|
||||||
|
opMu sync.Mutex // сериализует nft
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
enabled bool // копия настройки в памяти: cached() зовут внутри циклов по строкам users, а
|
||||||
|
// соединение с SQLite одно — запрос в базу оттуда ждал бы его вечно
|
||||||
|
tcp bool
|
||||||
|
lastError string
|
||||||
|
cacheAt time.Time
|
||||||
|
cache map[string]bool
|
||||||
|
cacheHits float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTorrent(s *Server) *Torrent {
|
||||||
|
t := &Torrent{s: s}
|
||||||
|
t.enabled = t.Settings().Enabled
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Torrent) Settings() TorrentSettings {
|
||||||
|
return TorrentSettings{Enabled: t.s.getCfg("torrentBlock", "0") == "1"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Torrent) script(tcp bool) string {
|
||||||
|
iface := t.s.iface
|
||||||
|
var b strings.Builder
|
||||||
|
// add+delete в одной транзакции: таблица пересоздаётся атомарно, без окна без правил.
|
||||||
|
b.WriteString("\nadd table inet " + torrentTable + "\ndelete table inet " + torrentTable + "\ntable inet " + torrentTable + " {\n")
|
||||||
|
b.WriteString(" counter hits {\n }\n")
|
||||||
|
b.WriteString(" set seen {\n type ipv4_addr\n flags dynamic, timeout\n timeout " + torrentSeenTTL + "\n }\n")
|
||||||
|
b.WriteString(" chain block {\n ct mark set ct mark or " + torrentMark + "\n update @seen { ip saddr }\n counter name \"hits\"\n drop\n }\n")
|
||||||
|
b.WriteString(" chain udp_sig {" + torrentUDPRules + "\n }\n")
|
||||||
|
if tcp {
|
||||||
|
b.WriteString(" chain tcp_sig {" + torrentTCPRules + "\n }\n")
|
||||||
|
}
|
||||||
|
b.WriteString(" chain forward {\n type filter hook forward priority -10; policy accept;\n")
|
||||||
|
b.WriteString(" ct mark and " + torrentMark + " == " + torrentMark + " drop\n")
|
||||||
|
b.WriteString(" iifname \"" + iface + "\" meta nfproto ipv4 meta l4proto udp jump udp_sig\n")
|
||||||
|
if tcp {
|
||||||
|
b.WriteString(" iifname \"" + iface + "\" meta nfproto ipv4 meta l4proto tcp jump tcp_sig\n")
|
||||||
|
}
|
||||||
|
b.WriteString(" }\n}\n")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Torrent) applyNow() error {
|
||||||
|
if !t.Settings().Enabled {
|
||||||
|
t.removeNow()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Сначала полный набор; старый nft/ядро не знают @ih — тогда без TCP, но UDP (DHT, трекеры,
|
||||||
|
// uTP) всё равно режем: это и есть основная часть.
|
||||||
|
tcp := true
|
||||||
|
err := shExecStdin("nft", []string{"-f", "-"}, t.script(true))
|
||||||
|
if err != nil {
|
||||||
|
tcp = false
|
||||||
|
if err2 := shExecStdin("nft", []string{"-f", "-"}, t.script(false)); err2 != nil {
|
||||||
|
return err2
|
||||||
|
}
|
||||||
|
t.s.log.Warn("torrent: TCP-сигнатуры не поддерживаются, блокируем только UDP", "nft", err.Error())
|
||||||
|
}
|
||||||
|
t.mu.Lock()
|
||||||
|
t.tcp, t.lastError, t.cache = tcp, "", nil
|
||||||
|
t.mu.Unlock()
|
||||||
|
t.s.log.Info("torrent block applied", "tcp", tcp)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Torrent) removeNow() {
|
||||||
|
shExec("nft", "delete", "table", "inet", torrentTable)
|
||||||
|
t.mu.Lock()
|
||||||
|
t.tcp, t.cache = false, nil
|
||||||
|
t.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Torrent) noteError(err error) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
if m := err.Error(); m != t.lastError {
|
||||||
|
t.s.log.Warn("torrent: " + m)
|
||||||
|
t.lastError = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply не бросает: без nft awg-ctrl должен работать дальше (как шейпинг).
|
||||||
|
func (t *Torrent) Apply() {
|
||||||
|
t.opMu.Lock()
|
||||||
|
err := t.applyNow()
|
||||||
|
t.opMu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
t.noteError(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update сохраняет и применяет; ошибку отдаёт вызывающему — оператору нужна причина.
|
||||||
|
func (t *Torrent) Update(st TorrentSettings) error {
|
||||||
|
v := "0"
|
||||||
|
if st.Enabled {
|
||||||
|
v = "1"
|
||||||
|
}
|
||||||
|
t.s.setCfg("torrentBlock", v)
|
||||||
|
t.mu.Lock()
|
||||||
|
t.enabled, t.cache = st.Enabled, nil
|
||||||
|
t.mu.Unlock()
|
||||||
|
t.opMu.Lock()
|
||||||
|
err := t.applyNow()
|
||||||
|
t.opMu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
t.mu.Lock()
|
||||||
|
t.lastError = err.Error()
|
||||||
|
t.mu.Unlock()
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// read — набор seen и счётчик из ядра. ok=false — таблицы нет.
|
||||||
|
func (t *Torrent) read() (seen map[string]bool, hits float64, ok bool) {
|
||||||
|
out, err := shExec("nft", "-j", "list", "set", "inet", torrentTable, "seen")
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, false
|
||||||
|
}
|
||||||
|
seen = map[string]bool{}
|
||||||
|
var doc struct {
|
||||||
|
Nftables []struct {
|
||||||
|
Set *struct {
|
||||||
|
Elem []json.RawMessage `json:"elem"`
|
||||||
|
} `json:"set"`
|
||||||
|
Counter *struct {
|
||||||
|
Packets float64 `json:"packets"`
|
||||||
|
} `json:"counter"`
|
||||||
|
} `json:"nftables"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal([]byte(out), &doc) == nil {
|
||||||
|
for _, it := range doc.Nftables {
|
||||||
|
if it.Set == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Элемент с таймаутом — {"elem": {"val": "10.9.0.2", ...}}, без него — просто строка.
|
||||||
|
for _, raw := range it.Set.Elem {
|
||||||
|
var ip string
|
||||||
|
if json.Unmarshal(raw, &ip) != nil {
|
||||||
|
var e struct {
|
||||||
|
Elem struct {
|
||||||
|
Val string `json:"val"`
|
||||||
|
} `json:"elem"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(raw, &e) == nil {
|
||||||
|
ip = e.Elem.Val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ip != "" {
|
||||||
|
seen[ip] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out, err := shExec("nft", "-j", "list", "counter", "inet", torrentTable, "hits"); err == nil {
|
||||||
|
doc.Nftables = nil
|
||||||
|
if json.Unmarshal([]byte(out), &doc) == nil {
|
||||||
|
for _, it := range doc.Nftables {
|
||||||
|
if it.Counter != nil {
|
||||||
|
hits = it.Counter.Packets
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return seen, hits, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// cached не трогает базу (только память и nft) — его можно звать где угодно.
|
||||||
|
func (t *Torrent) cached() (map[string]bool, float64, bool) {
|
||||||
|
t.mu.Lock()
|
||||||
|
if !t.enabled {
|
||||||
|
t.mu.Unlock()
|
||||||
|
return nil, 0, false
|
||||||
|
}
|
||||||
|
if t.cache != nil && time.Since(t.cacheAt) < torrentCacheS {
|
||||||
|
c, h := t.cache, t.cacheHits
|
||||||
|
t.mu.Unlock()
|
||||||
|
return c, h, true
|
||||||
|
}
|
||||||
|
t.mu.Unlock()
|
||||||
|
seen, hits, ok := t.read()
|
||||||
|
if !ok {
|
||||||
|
return nil, 0, false
|
||||||
|
}
|
||||||
|
t.mu.Lock()
|
||||||
|
t.cache, t.cacheHits, t.cacheAt = seen, hits, time.Now()
|
||||||
|
t.mu.Unlock()
|
||||||
|
return seen, hits, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeenSet — кто пытался открыть торрент-соединение за последние torrentSeenTTL. Берите его до
|
||||||
|
// запроса к базе, а не внутри цикла по строкам: так на открытом курсоре не висит вызов nft.
|
||||||
|
func (t *Torrent) SeenSet() map[string]bool {
|
||||||
|
seen, _, _ := t.cached()
|
||||||
|
out := make(map[string]bool, len(seen))
|
||||||
|
for ip := range seen {
|
||||||
|
out[ip] = true
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeenList — IP с меткой «торренты», по порядку; шейпинг держит их в фоновом классе.
|
||||||
|
func (t *Torrent) SeenList() []string {
|
||||||
|
seen, _, _ := t.cached()
|
||||||
|
out := make([]string, 0, len(seen))
|
||||||
|
for ip := range seen {
|
||||||
|
out = append(out, ip)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Torrent) Status() TorrentStatus {
|
||||||
|
st := TorrentStatus{Settings: t.Settings(), Seen: []TorrentPeer{}}
|
||||||
|
seen, hits, active := t.cached()
|
||||||
|
t.mu.Lock()
|
||||||
|
st.TCP, st.Error = t.tcp && active, t.lastError
|
||||||
|
t.mu.Unlock()
|
||||||
|
st.Active, st.Hits = active, hits
|
||||||
|
if len(seen) == 0 {
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
names := map[string]string{}
|
||||||
|
if rows, err := t.s.db.Query("SELECT name, ip FROM users"); err == nil {
|
||||||
|
for rows.Next() {
|
||||||
|
var n, ip string
|
||||||
|
if rows.Scan(&n, &ip) == nil {
|
||||||
|
names[ip] = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
}
|
||||||
|
for ip := range seen {
|
||||||
|
st.Seen = append(st.Seen, TorrentPeer{IP: ip, Name: names[ip]})
|
||||||
|
}
|
||||||
|
sort.Slice(st.Seen, func(i, j int) bool { return st.Seen[i].IP < st.Seen[j].IP })
|
||||||
|
return st
|
||||||
|
}
|
||||||
@@ -434,6 +434,21 @@ func (m *Metrics) render() string {
|
|||||||
out.add("awg_shaping_qdisc_drops_total", "counter", "Пакетов, отброшенных CAKE.", dirs(func(x any, dir string) any { return field(x, dir, "drops") }))
|
out.add("awg_shaping_qdisc_drops_total", "counter", "Пакетов, отброшенных CAKE.", dirs(func(x any, dir string) any { return field(x, dir, "drops") }))
|
||||||
out.add("awg_shaping_qdisc_backlog_bytes", "gauge", "Байт в очереди CAKE.", dirs(func(x any, dir string) any { return field(x, dir, "backlog") }))
|
out.add("awg_shaping_qdisc_backlog_bytes", "gauge", "Байт в очереди CAKE.", dirs(func(x any, dir string) any { return field(x, dir, "backlog") }))
|
||||||
|
|
||||||
|
// Блокировка BitTorrent
|
||||||
|
tb := func(fn func(x any) any) []sample {
|
||||||
|
return per(func(s serverInfo, d any) []sample {
|
||||||
|
x := field(d, "torrent")
|
||||||
|
if !truthy(x) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []sample{{L(s), fn(x)}}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
out.add("awg_torrent_block_enabled", "gauge", "Блокировка BitTorrent включена в настройках (1/0).", tb(func(x any) any { return b2f(truthy(field(x, "settings", "enabled"))) }))
|
||||||
|
out.add("awg_torrent_block_active", "gauge", "nft-таблица блокировки реально стоит (1/0).", tb(func(x any) any { return b2f(truthy(field(x, "active"))) }))
|
||||||
|
out.add("awg_torrent_blocked_total", "counter", "Заблокированных попыток торрент-соединений с последнего применения правил.", tb(func(x any) any { return or0(field(x, "hits")) }))
|
||||||
|
out.add("awg_torrent_devices", "gauge", "Устройств, пытавшихся качать торренты за последние 10 минут.", tb(func(x any) any { return float64(len(arr(field(x, "seen")))) }))
|
||||||
|
|
||||||
if m.perPeer {
|
if m.perPeer {
|
||||||
peers := func(fn func(p any) any) []sample {
|
peers := func(fn func(p any) any) []sample {
|
||||||
return per(func(s serverInfo, d any) []sample {
|
return per(func(s serverInfo, d any) []sample {
|
||||||
@@ -450,6 +465,7 @@ func (m *Metrics) render() string {
|
|||||||
}
|
}
|
||||||
out.add("awg_peer_online", "gauge", "Пир на связи (1/0).", peers(func(p any) any { return b2f(truthy(field(p, "online"))) }))
|
out.add("awg_peer_online", "gauge", "Пир на связи (1/0).", peers(func(p any) any { return b2f(truthy(field(p, "online"))) }))
|
||||||
out.add("awg_peer_background", "gauge", "Пир в фоновом классе: канал занят, а он давно качает больше своей доли (1/0).", peers(func(p any) any { return b2f(field(p, "priority") == "background") }))
|
out.add("awg_peer_background", "gauge", "Пир в фоновом классе: канал занят, а он давно качает больше своей доли (1/0).", peers(func(p any) any { return b2f(field(p, "priority") == "background") }))
|
||||||
|
out.add("awg_peer_torrent", "gauge", "Пир пытался открыть торрент-соединение за последние 10 минут (1/0).", peers(func(p any) any { return b2f(truthy(field(p, "torrent"))) }))
|
||||||
out.add("awg_peer_received_bytes_total", "counter", "Принято от пира, байт.", peers(func(p any) any { return field(p, "rx") }))
|
out.add("awg_peer_received_bytes_total", "counter", "Принято от пира, байт.", peers(func(p any) any { return field(p, "rx") }))
|
||||||
out.add("awg_peer_sent_bytes_total", "counter", "Отправлено пиру, байт.", peers(func(p any) any { return field(p, "tx") }))
|
out.add("awg_peer_sent_bytes_total", "counter", "Отправлено пиру, байт.", peers(func(p any) any { return field(p, "tx") }))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -377,6 +377,7 @@ type devStat struct {
|
|||||||
lastHS float64
|
lastHS float64
|
||||||
rx, tx float64
|
rx, tx float64
|
||||||
background bool
|
background bool
|
||||||
|
torrent bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// masterDevices — статистика устройства по всем серверам ключа: онлайн, если есть свежий
|
// masterDevices — статистика устройства по всем серверам ключа: онлайн, если есть свежий
|
||||||
@@ -420,6 +421,7 @@ func (s *Sub) masterDevices(m *masterRow) ([]any, error) {
|
|||||||
st.tx += numOr(field(p, "tx"), 0)
|
st.tx += numOr(field(p, "tx"), 0)
|
||||||
// Устройство стоит на каждом сервере со своим IP; фоновым его делает любой из них.
|
// Устройство стоит на каждом сервере со своим IP; фоновым его делает любой из них.
|
||||||
st.background = st.background || field(p, "priority") == "background"
|
st.background = st.background || field(p, "priority") == "background"
|
||||||
|
st.torrent = st.torrent || truthy(field(p, "torrent"))
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
@@ -435,10 +437,14 @@ func (s *Sub) masterDevices(m *masterRow) ([]any, error) {
|
|||||||
st = &devStat{}
|
st = &devStat{}
|
||||||
}
|
}
|
||||||
okN := 0
|
okN := 0
|
||||||
|
ips := []any{} // IP устройства на каждом сервере ключа, где он уже выдан
|
||||||
for _, l := range links {
|
for _, l := range links {
|
||||||
if l.PeerPub.Valid && l.PeerPub.String == d.PubKey && l.IP.Valid && l.IP.String != "" {
|
if l.PeerPub.Valid && l.PeerPub.String == d.PubKey && l.IP.Valid && l.IP.String != "" {
|
||||||
okN++
|
okN++
|
||||||
}
|
}
|
||||||
|
if l.IP.Valid && l.IP.String != "" {
|
||||||
|
ips = append(ips, jsjson.Object{{Key: "server_id", Val: l.ServerID}, {Key: "ip", Val: l.IP.String}})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
prio := "normal"
|
prio := "normal"
|
||||||
if st.background {
|
if st.background {
|
||||||
@@ -449,7 +455,8 @@ func (s *Sub) masterDevices(m *masterRow) ([]any, error) {
|
|||||||
{Key: "version", Val: d.Version}, {Key: "created_at", Val: d.CreatedAt}, {Key: "last_seen", Val: nullInt(d.LastSeen)},
|
{Key: "version", Val: d.Version}, {Key: "created_at", Val: d.CreatedAt}, {Key: "last_seen", Val: nullInt(d.LastSeen)},
|
||||||
{Key: "rekey_requested", Val: d.RekeyRequested == 1}, {Key: "online", Val: st.online}, {Key: "lastHandshake", Val: st.lastHS},
|
{Key: "rekey_requested", Val: d.RekeyRequested == 1}, {Key: "online", Val: st.online}, {Key: "lastHandshake", Val: st.lastHS},
|
||||||
{Key: "rx", Val: st.rx}, {Key: "tx", Val: st.tx}, {Key: "priority", Val: prio},
|
{Key: "rx", Val: st.rx}, {Key: "tx", Val: st.tx}, {Key: "priority", Val: prio},
|
||||||
{Key: "servers_total", Val: len(links)}, {Key: "servers_ok", Val: okN},
|
{Key: "torrent", Val: st.torrent},
|
||||||
|
{Key: "servers_total", Val: len(links)}, {Key: "servers_ok", Val: okN}, {Key: "ips", Val: ips},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
|
|||||||
Reference in New Issue
Block a user