8 Commits
Author SHA1 Message Date
maeneko 0687650d26 Bump to Beta 0.4.4
ci / check (push) Successful in 24m29s
release / release (push) Successful in 19m48s
2026-10-20 20:39:09 +03:00
maeneko 3511b8c6c3 Master keys: a down server no longer breaks the device list, 403 for keys outside the API key's scope
masterDevices skips a server that did not answer and reports it as
unreachable; peers are fetched once per server per pass. The tab loads each
key on its own, counts devices from the key when the list is missing (and still
asks before unbinding), drops stale responses, labels an IP from another
server and blocks a double create. masterFor answers 403 instead of 404 for a
key that exists but is outside the API key's servers, so a client does not
mistake it for a deleted one. updateMaster checks every statement in its
transaction.
2026-10-20 20:39:09 +03:00
maeneko 28ccc17bde Bump to Beta 0.4.3
ci / check (push) Successful in 22m56s
release / release (push) Successful in 17m49s
2026-10-19 12:23:21 +03:00
maeneko d6fc2049c3 Master keys tab: same columns as users, device IPs, per-key summary
Status, priority/torrent chips and IP columns like the users tab; the API
returns each device's IP per server. The key row sums its devices (online,
priority, traffic); key UUID and device ID sit under the name, copy on click.
2026-10-19 12:23:21 +03:00
maeneko ea91265674 Bump to Beta 0.4.2.1
ci / check (push) Successful in 22m38s
release / release (push) Successful in 17m42s
2026-10-18 00:09:34 +03:00
maeneko de8a32ac41 Fix awg-ctrl hanging on the torrent flag; log out on an expired session
The torrent flag was read inside loops over users rows (users/stats,
peers, metrics) and each read queried the config table. SQLite has a
single connection here, so the nested query waited for it forever: the
first stats request or Prometheus scrape locked awg-ctrl for good, and
the panel showed no users and timed out.

- the block setting is kept in memory (loaded at start, updated on
  save), so the flag never touches the database
- the set of flagged IPs is taken once before the query, not per row
- regression test: collectMetrics must finish with users in the table

Panel: a 401 from the panel itself (its JWT not accepted) now returns to
the login screen; a 401 relayed from a node's awg-ctrl does not.
2026-10-18 00:08:17 +03:00
maeneko a84845cecb Bump to Beta 0.4.2
ci / check (push) Successful in 22m15s
release / release (push) Successful in 17m39s
2026-10-17 23:35:30 +03:00
maeneko c73da602d6 Block BitTorrent on the server
- awg-ctrl: optional nft table awg_torrent (GET/PUT /api/torrent, off by
  default). A connection is judged by its first packets, like Xray's
  sniffing: DHT, UDP trackers, uTP SYN, and with @ih support the plain
  TCP handshake and HTTP announce. A match marks the connection in
  conntrack and drops it whole; without @ih only the UDP part is used
- devices that tried are kept in a 10-minute set and flagged as
  "torrent" in user and device stats; with shaping on they sit in the
  background class while the flag lasts
- metrics: awg_torrent_block_enabled/active, awg_torrent_blocked_total,
  awg_torrent_devices and per-peer awg_peer_torrent
- panel: "Торренты" section in server settings, "торренты" chip on users
  and devices; Save sends only what changed
2026-10-17 23:35:30 +03:00
28 changed files with 996 additions and 157 deletions
+1 -1
View File
@@ -11,4 +11,4 @@
BRAND=Forgetting
CHANNEL=Beta
VERSION=0.4.1
VERSION=0.4.4
+1 -1
View File
@@ -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.4" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
| `GET /health` | `{ "status", "server", "ip", "gen", "awg": { "status", "peers", "module", "tools" } }`. `200`, если интерфейс поднят, иначе `503` и `status: "degraded"` |
| `GET /awg/status` | `{ "up": true, "peers": 3, "publicKey": "…" }` |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "awg-agent",
"version": "0.4.1",
"version": "0.4.4",
"private": true,
"description": "Агент ноды: держит исходящее WSS-соединение к core и исполняет запросы на локальном awg-ctrl",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "awg-control",
"version": "0.4.1",
"version": "0.4.4",
"description": "",
"main": "dist/index.js",
"scripts": {
+43 -5
View File
@@ -57,7 +57,11 @@ const masters: MK[] = [{ id: mkId++, uuid: crypto.randomUUID(), label: "Семь
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 } : {}) });
// Мок: устройство «догнано» на всех серверах ключа, кроме 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);
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 on = settings.enabled;
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,
};
};
@@ -178,10 +183,36 @@ app.put("/api/shaping", (q, r) => {
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) }));
// Приоритет в моке: пока на сервере 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", (q, r) => {
const users = U_(q);
@@ -245,7 +276,14 @@ app.get("/ui/masterkeys/:id/link", (q, r) => {
const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" });
r.json({ link: encodeSenLink({ tls: false, addrs: [{ host: "203.0.113.7", port: 41234 }], secret: m.secret, signPub, name: m.label }), tls: false });
});
app.get("/ui/masterkeys/:id/devices", (q, r) => { const m = find(q.params.id); m ? r.json({ devices: m.devs.map(d => devOut(m, d)) }) : r.status(404).json({ error: "Не найден" }); });
// MOCK_MK_FAIL=<id ключа> — /devices этого ключа отвечает 502; MOCK_MK_DOWN=<id сервера> — этот сервер «не ответил».
app.get("/ui/masterkeys/:id/devices", (q, r) => {
const m = find(q.params.id);
if (!m) return r.status(404).json({ error: "Не найден" });
if (process.env.MOCK_MK_FAIL === String(m.id)) return r.status(502).json({ error: "awg-ctrl недоступен" });
const down = Number(process.env.MOCK_MK_DOWN);
r.json({ devices: m.devs.map(d => devOut(m, d)), unreachable: m.servers.filter(s => s === down) });
});
app.delete("/ui/devices/:id", (q, r) => { for (const m of masters) m.devs = m.devs.filter(d => d.id !== Number(q.params.id)); r.json({ success: true }); });
app.post("/ui/devices/:id/rekey", (q, r) => { masters.forEach(m => m.devs.forEach(d => { if (d.id === Number(q.params.id)) d.rekey_requested = true; })); r.json({}); });
app.post("/ui/devices/:id/psk", (_q, r) => r.json({ pending: 0 }));
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "awg-ui",
"version": "0.4.1",
"version": "0.4.4",
"private": true,
"scripts": {
"dev": "vite",
+15
View File
@@ -417,6 +417,7 @@ body {
}
.node-modal {
width: 100%; max-width: 520px;
max-height: 100%; overflow-y: auto;
background: var(--surface-bright);
border: 1px solid var(--outline-variant);
border-radius: var(--r-card);
@@ -445,6 +446,9 @@ body {
.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-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 .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-ip, .server-pick-gen { font-size: 12px; color: var(--neutral-text); }
.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; }
+18
View File
@@ -149,6 +149,24 @@ export default function App() {
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(() => {
const saved = localStorage.getItem(TOKEN_KEY);
+8 -5
View File
@@ -6,16 +6,19 @@ import type { Priority } from '../lib/shared';
// Текущая приоритетность ключа/устройства. «Фоновый» — только пока канал занят, а устройство давно
// качает заметно больше своей доли: оно уступает остальным, но свободный канал занимает по-прежнему.
// Когда деление канала выключено или канал свободен, у всех «обычный». Без поля (awg-ctrl старше
// деления канала) чип не показываем: «обычный» там было бы выдумкой.
export default function PriorityChip({ priority }: { priority?: Priority }) {
// деления канала) чип не показываем: «обычный» там было бы выдумкой. С меткой «торренты» устройство
// фоновое независимо от загрузки канала — подсказка говорит об этом.
export default function PriorityChip({ priority, torrent }: { priority?: Priority; torrent?: boolean }) {
if (!priority) return null;
const bg = priority === 'background';
return (
<span
className="tip-wrap"
data-tip={bg
? 'Канал занят, а это устройство давно качает больше своей доли — пока оно уступает остальным'
: 'Обычный приоритет: канал делится поровну'}
data-tip={!bg
? 'Обычный приоритет: канал делится поровну'
: torrent
? 'Пытается качать торренты — пока висит метка, устройство уступает остальным'
: 'Канал занят, а это устройство давно качает больше своей доли — пока оно уступает остальным'}
>
<span className={`chip ${bg ? 'chip--warm' : 'chip--offline'}`}>
<span className="chip-dot" />{bg ? 'фоновый' : 'обычный'}
+93 -7
View File
@@ -3,10 +3,10 @@
// LICENSE file in the root directory of this source tree.
import { useEffect, useState } from 'react';
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). Сами настройки живут в
// awg-ctrl этого сервера — запрос идёт обычным прокси с X-Server-Id (App выбирает сервер
// Настройки сервера: деление канала между устройствами (CAKE) и блокировка BitTorrent. Сами
// настройки живут в awg-ctrl этого сервера — запрос идёт обычным прокси с X-Server-Id (App выбирает сервер
// до открытия модалки), так что ядро и ноды настраиваются одинаково.
export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
token: string;
@@ -22,6 +22,9 @@ export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
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) => {
setSt(s);
@@ -33,6 +36,20 @@ export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
const errText = (e: unknown, fallback: string) =>
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(() => {
let alive = true;
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 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 () => {
setBusy(true); setError('');
const done: string[] = [];
try {
if (shapingDirty) {
const s = await apiFetch('PUT', '/api/shaping', token, {
enabled, auto, downMbit: Number(down) || 0, upMbit: Number(up) || 0,
});
fill(s as ShapingStatus);
showMsg(enabled ? 'Деление канала включено' : 'Деление канала выключено');
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, 'Не удалось сохранить')); }
finally { setBusy(false); }
};
@@ -70,7 +101,8 @@ export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
return (
<div className="node-scrim" onClick={onClose}>
<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 ? (
<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>}
<div className="node-modal-actions">
{!unsupported && (
<button className="btn btn--primary" onClick={save} disabled={!st || busy || !valid}>
{!(unsupported && tUnsupported) && (
<button className="btn btn--primary" onClick={save} disabled={busy || !valid || !(shapingDirty || torrentDirty)}>
Сохранить
</button>
)}
+14
View File
@@ -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>
);
}
+11
View File
@@ -19,6 +19,7 @@ export interface User {
rx?: number;
tx?: number;
priority?: Priority; // нет у awg-ctrl старше деления канала
torrent?: boolean; // пытался качать торренты за последние 10 минут (при включённой блокировке)
}
export interface ServerInfo {
name: string;
@@ -79,6 +80,16 @@ export interface ShapingStatus {
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.
export function cmpVersion(a: string, b: string): number {
const pa = a.split('-')[0].split('.').map(Number), pb = b.split('-')[0].split('.').map(Number);
+153 -52
View File
@@ -1,12 +1,13 @@
// 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.
import { Fragment, useState, useEffect, useCallback } from 'react';
import { Fragment, useState, useEffect, useCallback, useRef } from 'react';
import QRCode from 'qrcode';
import { apiFetch, copyText, bytes, timeAgo, type NodeInfo, type PageProps, type Priority } from '../../lib/shared';
import { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings } from '../../components/icons';
import { apiFetch, copyText, bytes, timeAgo, getServerId, type NodeInfo, type PageProps, type Priority } from '../../lib/shared';
import { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings, IcoGlobe } from '../../components/icons';
import ServerPicker, { serverName } from '../../components/ServerPicker';
import PriorityChip from '../../components/PriorityChip';
import TorrentChip from '../../components/TorrentChip';
import './masterkeys.css';
// Вкладка «Мастер-ключи»: 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 Device {
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; // на скольких серверах ключа уже стоит его текущий ключ
ips?: { server_id: number; ip: string }[]; // IP на каждом сервере ключа (старая панель не отдаёт)
}
// Устройство ещё не на всех серверах ключа (нода не в сети при регистрации или смене ключа) —
@@ -31,6 +33,22 @@ const platformLabel = (d: Device): string =>
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 устройства на выбранном сервере (на каждом сервере ключа он свой), иначе — на первом;
// other — адрес с другого сервера, его надо подписать, иначе его примут за адрес выбранного.
const ipPick = (d: Device): { ip: string; serverId?: number; other: boolean } => {
const own = d.ips?.find(x => x.server_id === getServerId());
const pick = own ?? d.ips?.[0];
return { ip: pick?.ip ?? '—', serverId: pick?.server_id, other: !own && !!pick };
};
export default function MasterKeysPage({ token, showMsg }: PageProps) {
const [keys, setKeys] = useState<MasterKey[]>([]);
const [enabled, setEnabled] = useState(true);
@@ -42,18 +60,40 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
const [folded, setFolded] = useState<Set<number>>(new Set()); // свёрнутые узлы
const [tools, setTools] = useState<number | null>(null); // узел с открытыми настройками
const [limitEdit, setLimitEdit] = useState('');
const [failed, setFailed] = useState<Set<number>>(new Set()); // ключи, чьи устройства не загрузились
const [down, setDown] = useState<Record<number, number[]>>({}); // серверы ключа, не ответившие на статистику
const [creating, setCreating] = useState(false);
const loadSeq = useRef(0); // номер последней загрузки: ответ более старой отбрасывается
const creatingRef = useRef(false);
const [modal, setModal] = useState<{ label: string; link: string; tls: boolean; dataUrl: string } | null>(null);
const load = useCallback(async () => {
const seq = ++loadSeq.current;
try {
const d = await apiFetch('GET', '/ui/masterkeys', token);
const list: MasterKey[] = d.keys ?? [];
if (seq !== loadSeq.current) return;
setKeys(list); setEnabled(!!d.enabled); setTls(!!d.tls);
apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {});
const all = await Promise.all(list.filter(k => !k.deleting).map(k =>
apiFetch('GET', `/ui/masterkeys/${k.id}/devices`, token).then(r => [k.id, r.devices ?? []] as const)));
setDevs(Object.fromEntries(all));
} catch { showMsg('Ошибка загрузки мастер-ключей'); }
// Каждый ключ грузится сам по себе: сбой одного не должен прятать устройства остальных.
const live = list.filter(k => !k.deleting);
const res = await Promise.allSettled(live.map(k =>
apiFetch('GET', `/ui/masterkeys/${k.id}/devices`, token)));
if (seq !== loadSeq.current) return;
const bad = new Set<number>(), unreachable: Record<number, number[]> = {};
const fresh: Record<number, Device[]> = {};
res.forEach((r, i) => {
const id = live[i].id;
if (r.status === 'fulfilled') {
fresh[id] = r.value.devices ?? [];
if (r.value.unreachable?.length) unreachable[id] = r.value.unreachable;
} else bad.add(id);
});
// Не загрузилось — оставляем прежний список (устаревший лучше пустого), но помечаем.
setDevs(prev => Object.fromEntries(live.flatMap(k =>
fresh[k.id] ? [[k.id, fresh[k.id]]] : prev[k.id] ? [[k.id, prev[k.id]]] : [])));
setFailed(bad); setDown(unreachable);
} catch { if (seq === loadSeq.current) showMsg('Ошибка загрузки мастер-ключей'); }
}, [token, showMsg]);
// Действие над ключом/устройством: выполнить, показать сообщение, обновить данные.
@@ -63,15 +103,17 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
}, [load, showMsg]);
const create = useCallback(async () => {
if (!label.trim()) return;
await act(async () => {
if (!label.trim() || creatingRef.current) return;
creatingRef.current = true; setCreating(true);
try { await act(async () => {
// Новый ключ отдаёт все серверы, которые уже подключались; набор потом правится в настройках ключа.
const ready = nodes.filter(n => !n.pending).map(n => n.id);
await apiFetch('POST', '/ui/masterkeys', token, {
label: label.trim(), device_limit: Number(limit), ...(ready.length ? { servers: ready } : {}),
});
setLabel('');
}, 'Мастер-ключ создан');
}, 'Мастер-ключ создан'); }
finally { creatingRef.current = false; setCreating(false); }
}, [label, limit, nodes, token, act]);
const serverNames = useCallback((ids: number[]) => ids.map(id => serverName(nodes, id)).join(', '), [nodes]);
@@ -105,15 +147,19 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
}, [fetchLink, showMsg]);
// Лимит ниже числа устройств: сервер сразу отвяжет лишних, начиная с подключившихся последними, —
// поэтому сперва спросить и назвать, кого именно.
// поэтому сперва спросить и назвать, кого именно. Список устройств не загрузился — считаем по
// счётчику ключа и имён не называем, но спросить всё равно надо.
const saveLimit = useCallback(async (k: MasterKey) => {
const limit = Number(limitEdit);
const list = devs[k.id] ?? [];
const extra = list.length - limit;
const list = devs[k.id];
const have = list?.length ?? k.devices;
const extra = have - limit;
if (Number.isInteger(limit) && extra > 0) {
const names = [...list].sort((a, b) => b.created_at - a.created_at || b.id - a.id).slice(0, extra)
.map(d => `«${d.device_name || d.id}»`).join(', ');
if (!confirm(`Лимит ${limit} меньше, чем устройств (${list.length}). Будут отвязаны подключившиеся последними: ${names}. Продолжить?`)) return;
const who = list
? [...list].sort((a, b) => b.created_at - a.created_at || b.id - a.id).slice(0, extra)
.map(d => `«${d.device_name || d.id}»`).join(', ')
: `${extra} шт.`;
if (!confirm(`Лимит ${limit} меньше, чем устройств (${have}). Будут отвязаны подключившиеся последними: ${who}. Продолжить?`)) return;
}
try {
const r = await apiFetch('PATCH', `/ui/masterkeys/${k.id}`, token, { device_limit: limit });
@@ -253,17 +299,60 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
</div>
);
// UUID ключа в строке списка — как ID устройств под ним (та же ширина и обрезка),
// полностью — в подсказке, по клику копируется.
const uuidCell = (k: MasterKey) => (
<span className="tip-wrap mk-uuid-wrap" data-tip={`UUID ${k.uuid} · нажми, чтобы скопировать`}>
<button className="mk-uuid-btn" aria-label={`Скопировать UUID ${k.uuid}`}
// Метка ключа и под ней UUID — коротко, чтобы не раздувать колонку «Имя». Полностью — в подсказке,
// по клику копируется.
const nameCell = (k: MasterKey) => (
<span className="name-stack">
{k.label}
<span className="tip-wrap" data-tip={`UUID ${k.uuid} · нажми, чтобы скопировать`}>
<button className="copy-id" aria-label={`Скопировать UUID ${k.uuid}`}
onClick={() => copyText(k.uuid).then(() => showMsg('UUID скопирован'), () => showMsg('Не удалось скопировать'))}>
<code className="mk-device-id">{k.uuid}</code>
{k.uuid.slice(0, 8)}…
</button>
</span>
</span>
);
// Данные по ключу неполные: устройства не загрузились или сервер не ответил на статистику.
const dataFlag = (k: MasterKey) => {
if (failed.has(k.id)) return <span className="mk-flag" title="Список устройств не загрузился — показан последний известный"> · устройства не загрузились</span>;
const d = down[k.id];
return d?.length ? <span className="mk-flag" title="Онлайн и трафик с этих серверов не получены"> · нет связи: {serverNames(d)}</span> : null;
};
// Сводка по устройствам ключа в его строке — видна и у свёрнутого ключа.
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, serverId, other } = ipPick(d);
const text = <code className="mk-ip">{ip}{other && serverId !== undefined ? ` · ${serverName(nodes, serverId)}` : ''}</code>;
return all.length > 1
? <span className="tip-wrap" data-tip={all.map(x => `${serverName(nodes, x.server_id)}: ${x.ip}`).join(' · ')}>{text}</span>
: text;
};
const caret = (k: MasterKey) => (
<button className={`mk-caret${folded.has(k.id) ? ' mk-caret--folded' : ''}`}
aria-label={folded.has(k.id) ? 'Развернуть' : 'Свернуть'} aria-expanded={!folded.has(k.id)}
@@ -278,13 +367,17 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
<span className="mk-device-name">{d.device_name || 'Устройство'}</span>
<code className="mk-device-id" title={`ID устройства: ${d.device_id}`}>{d.device_id}</code>
</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>
</div>
<span className={`chip chip--${d.online ? 'online' : 'offline'}`}>
<span className="chip-dot" />{d.online ? 'Онлайн' : 'Офлайн'}
</span>
<PriorityChip priority={d.priority} />
{statusChip(d)}
{/* как строка IP в карточке пользователя: адрес и чипы приоритета/торрентов */}
<div className="mk-device-ip">
<IcoGlobe />
{ipCell(d)}
<PriorityChip priority={d.priority} torrent={d.torrent} />
<TorrentChip torrent={d.torrent} />
</div>
<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=" tx">↑</i><b>{bytes(d.tx)}</b></span>
@@ -309,7 +402,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
<input className="field mk-limit-input" type="number" min={1} max={100} title="Лимит устройств"
aria-label="Лимит устройств" value={limit} onChange={e => setLimit(e.target.value)} />
<div className="toolbar-btns">
<button className="btn btn--primary" onClick={create}><IcoPlus /> Создать ключ</button>
<button className="btn btn--primary" onClick={create} disabled={creating}><IcoPlus /> Создать ключ</button>
</div>
</div>
@@ -318,8 +411,8 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
<table className="mk-table">
<thead>
<tr>
<th>Имя</th><th>ID устройства</th><th>Платформа</th><th>Статус</th>
<th>↓ rx</th><th>↑ tx</th><th>Был</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>
</tr>
</thead>
<tbody>
@@ -328,39 +421,46 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
) : keys.map(k => {
if (k.deleting) return (
<tr key={k.id} className="mk-tr-master mk-last">
<td><span className="mk-name"><IcoKey />{k.label}</span></td>
<td className="td-mono">{uuidCell(k)}</td>
<td colSpan={7}>{deletingChip(k)}</td>
<td><span className="mk-name"><IcoKey />{nameCell(k)}</span></td>
<td colSpan={8}>{deletingChip(k)}</td>
</tr>
);
const list = devs[k.id] ?? [];
const free = Math.max(0, k.device_limit - list.length);
const count = devs[k.id]?.length ?? k.devices;
const free = Math.max(0, k.device_limit - count);
const open = !folded.has(k.id);
return (
<Fragment key={k.id}>
<tr className="mk-tr-master">
<td><span className="mk-name">{caret(k)}<IcoKey />{k.label}</span></td>
<td className="td-mono">{uuidCell(k)}</td>
<td colSpan={5}></td>
<td>{meter(k, list.length)}</td>
<td><span className="mk-name">{caret(k)}<IcoKey />{nameCell(k)}{dataFlag(k)}</span></td>
{summary(list)}
<td>{meter(k, count)}</td>
<td className="td-actions mk-td-actions">{actions(k)}</td>
</tr>
{tools === k.id && <tr className="mk-tr-tools"><td colSpan={9}>{toolbox(k)}</td></tr>}
{open && list.map((d, i) => (
<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>
<span className={`chip chip--${d.online ? 'online' : 'offline'}`}>
<span className="chip-dot" />{d.online ? 'Онлайн' : 'Офлайн'}
<span className="mk-indent">
<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>
</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>}
{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 className="td-actions mk-td-actions">{deviceActions(d)}</td>
</tr>
@@ -391,15 +491,16 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
</li>
);
const list = devs[k.id] ?? [];
const free = Math.max(0, k.device_limit - list.length);
const count = devs[k.id]?.length ?? k.devices;
const free = Math.max(0, k.device_limit - count);
const isFolded = folded.has(k.id);
return (
<li className="mk-root" key={k.id}>
<div className="mk-master">
{caret(k)}
<IcoKey />
<span className="mk-master-label">{k.label}<code className="mk-master-uuid">{k.uuid}</code></span>
{meter(k, list.length)}
<span className="mk-master-label">{k.label}{dataFlag(k)}<code className="mk-master-uuid">{k.uuid}</code></span>
{meter(k, count)}
{actions(k)}
</div>
{tools === k.id && toolbox(k)}
+10 -7
View File
@@ -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:hover td { background: var(--surface-container); }
.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 {
display: block;
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-device td, .mk-tr-free td { padding-top: 10px; padding-bottom: 10px; }
.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 {
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-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-metric { display: inline-flex; gap: 4px; }
.mk-metric i, .mk-metric b { font-style: normal; font-weight: 400; }
+5 -3
View File
@@ -9,6 +9,7 @@ import {
} from '../../lib/shared';
import { IcoPlus, IcoRefresh, IcoQR, IcoTrash, IcoGlobe } from '../../components/icons';
import PriorityChip from '../../components/PriorityChip';
import TorrentChip from '../../components/TorrentChip';
import './users.css';
// Вкладка «Пользователи»: форма создания + таблица (десктоп) / карточки (мобильный)
@@ -29,7 +30,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
const data = await apiFetch('GET', '/api/users/stats', tok);
setUsers(prev => prev.map(u => {
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 */ }
}, []);
@@ -187,7 +188,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
: 'никогда'}
</span>
</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>
{u.name}
{u.key_gen !== serverGen && (
@@ -254,7 +255,8 @@ export default function UsersPage({ token, showMsg }: PageProps) {
<div className="user-card-ip">
<IcoGlobe />
<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 className="user-card-metrics">
<div className="metric-block">
+4
View File
@@ -47,6 +47,7 @@ LICENSE file in the root directory of this source tree.
}
.user-card-ip {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
color: var(--neutral-text);
@@ -78,3 +79,6 @@ LICENSE file in the root directory of this source tree.
/* .tip-wrap на мобильной растягивается на всю ширину — метке это не нужно. */
.user-gen { width: auto; }
}
/* Чипы приоритета и торрентов в одной ячейке */
.user-chips { display: inline-flex; flex-wrap: wrap; gap: 6px; }
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "awg-cli",
"version": "0.4.1",
"version": "0.4.4",
"private": true,
"dependencies": {
"tsx": "^4.7.0",
+1 -1
View File
@@ -100,7 +100,7 @@ trap 'exec >&- 2>&-; wait "$LOG_PID" 2>/dev/null || true' EXIT
# ⚠️ Дублирует .env из репозитория: установщик собирает URL релиза и печатает
# баннер ДО того, как архив с .env скачан, поэтому взять их оттуда не может.
# При бампе версии правь оба места.
VERSION="0.4.1"
VERSION="0.4.4"
CHANNEL="${CHANNEL:-Beta}"
# Откуда брать архив и как называется продукт. По умолчанию — релизы на GitHub; источник
+6 -2
View File
@@ -31,6 +31,7 @@ type metricsPeer struct {
Rx float64 `json:"rx"`
Tx float64 `json:"tx"`
Priority string `json:"priority"`
Torrent bool `json:"torrent"`
}
type metricsHost struct {
@@ -65,6 +66,7 @@ type metricsBody struct {
TxBytes float64 `json:"tx_bytes"`
Link linkStats `json:"link"`
Shaping ShapingStatus `json:"shaping"`
Torrent TorrentStatus `json:"torrent"`
Host *metricsHost `json:"host"`
List []metricsPeer `json:"list"`
}
@@ -127,6 +129,7 @@ func (s *Server) collectMetrics() (*metricsBody, error) {
if st.Up {
stats = s.getPeersData()
}
torrent := s.torrent.SeenSet() // до запроса: внутри цикла по строкам — ни базы, ни nft
rows, err := s.db.Query("SELECT name, ip, pub_key FROM users WHERE vpn_key != ''")
if err != nil {
return nil, err
@@ -139,7 +142,7 @@ func (s *Server) collectMetrics() (*metricsBody, error) {
return nil, err
}
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()
devs, err := s.peersAll()
@@ -148,7 +151,7 @@ func (s *Server) collectMetrics() (*metricsBody, error) {
}
for _, p := range devs {
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
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"),
}
b.Shaping = s.shaping.Status()
b.Torrent = s.torrent.Status()
b.Host = readHost()
return b, nil
}
+37
View File
@@ -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
View File
@@ -73,6 +73,7 @@ func (s *Server) routes() []route {
}),
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 != ''")
if err != nil {
return result{}, err
@@ -87,7 +88,8 @@ func (s *Server) routes() []route {
}
ps := stats[pub]
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()
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) {
torrent := s.torrent.SeenSet()
stats := s.getPeersData()
peers, err := s.peersAll()
if err != nil {
@@ -147,7 +150,7 @@ func (s *Server) routes() []route {
ps := stats[p.PubKey]
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},
{"priority", s.shaping.PriorityOf(p.IP)}})
{"priority", s.shaping.PriorityOf(p.IP)}, {"torrent", torrent[p.IP]}})
}
return ok(ob{{"peers", list}})
}),
@@ -357,6 +360,21 @@ func (s *Server) routes() []route {
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) {
d, err := s.metricsCached()
if err != nil {
+3
View File
@@ -55,6 +55,7 @@ type Server struct {
c Config
shaping *Shaping
torrent *Torrent
metrics metricsCache
monitor monitorJob
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.shaping = newShaping(s)
s.torrent = newTorrent(s)
return s, nil
}
@@ -182,5 +184,6 @@ func (s *Server) Run() error {
// ни один из путей awg-quick up внутри awg-ctrl тогда не срабатывает.
go s.shaping.Apply()
s.shaping.Start()
go s.torrent.Apply() // nft-таблицы после перезагрузки нет — ставим заново
return <-errc
}
+55 -26
View File
@@ -20,6 +20,7 @@ import (
// загрузка (к клиенту) — CAKE root на awg1, dual-dsthost, diffserv3
// отдача (от клиента) — ingress awg1 → ifb-awg, CAKE dual-srchost, besteffort
// тяжёлые при перегрузке — DSCP CS1 (bulk-tin CAKE) через отдельную nft-таблицу
// с меткой «торренты» — туда же, пока метка висит (см. torrent.go), независимо от перегрузки
//
// Ничего не пишет в awg1.conf (PostUp не трогаем): всё применяется из процесса и
// идемпотентно. Правила tc пропадают вместе с интерфейсом, поэтому Apply зовётся на
@@ -123,6 +124,7 @@ type Shaping struct {
histOrder []string
totals []tick
heavy *orderedSet
applied *orderedSet // что сейчас стоит в nft-наборе: тяжёлые ∪ с меткой торрента
congested bool
lastRateDn float64
lastRateUp float64
@@ -138,7 +140,7 @@ func newShaping(s *Server) *Shaping {
sampleS := envNum("SHAPE_SAMPLE_S", 10)
windowS := envNum("SHAPE_WINDOW_S", 600)
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))),
}
}
@@ -266,13 +268,12 @@ func (h *Shaping) applyNow() error {
return err
}
}
h.mu.Lock()
heavy := h.heavy.list()
h.mu.Unlock()
heavy := h.desired()
if err := h.nftApply(heavy); err != nil {
return err
}
h.mu.Lock()
h.applied = setOf(heavy)
h.lastError = ""
h.mu.Unlock()
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)
h.mu.Lock()
h.heavy.clear()
h.applied.clear()
h.history, h.histOrder, h.prev = map[string][]tick{}, nil, map[string][2]float64{}
h.totals = nil
h.congested = false
@@ -319,6 +321,50 @@ func (h *Shaping) Update(st ShapingSettings) error {
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) ─────────
func (h *Shaping) pushTick(arr []tick, t tick) []tick {
@@ -391,6 +437,8 @@ func (h *Shaping) tick() {
h.tickNo++
tickNo := h.tickNo
h.mu.Unlock()
// В конце любого тика, в том числе пока окно не набралось: метка торрента не ждёт 10 минут.
defer h.syncNft()
// Страховка: интерфейс могли поднять руками (awg-quick down/up) — qdisc тогда исчез.
if tickNo%checkEvery == 0 && !h.hasCake(h.s.iface) {
@@ -443,18 +491,9 @@ func (h *Shaping) tick() {
h.lastRateUp = totalUp * 8 / dt / 1e6
if !st.Auto {
changed := h.heavy.size() > 0 || h.congested
h.heavy.clear()
h.congested = false
h.mu.Unlock()
if changed {
h.opMu.Lock()
err := h.nftApply(nil)
h.opMu.Unlock()
if err != nil {
h.noteError(err)
}
}
return
}
if len(h.totals) < h.windowTicks { // пока окно не набралось, решений не принимаем
@@ -507,21 +546,11 @@ func (h *Shaping) tick() {
}
}
}
var apply []string
if changed || wasCongested != h.congested {
h.heavy = next
apply = next.list()
h.s.log.Info("shaping: класс тяжёлых обновлён", "congested", h.congested, "avgMbit", math.Round(avg), "heavy", apply)
h.s.log.Info("shaping: класс тяжёлых обновлён", "congested", h.congested, "avgMbit", math.Round(avg), "heavy", next.list())
}
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() {
@@ -571,7 +600,7 @@ func (h *Shaping) qdiscStats(dev string) *QdiscStats {
func (h *Shaping) PriorityOf(ip string) string {
h.mu.Lock()
defer h.mu.Unlock()
if h.heavy.has(ip) {
if h.applied.has(ip) { // то, что реально стоит в nft: тяжёлые и с меткой торрента
return "background"
}
return "normal"
@@ -593,7 +622,7 @@ func (h *Shaping) Status() ShapingStatus {
devices++
}
}
heavy := h.heavy.list()
heavy := h.applied.list()
if heavy == nil {
heavy = []string{}
}
+310
View File
@@ -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
}
+16
View File
@@ -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_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 {
peers := func(fn func(p any) 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_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_sent_bytes_total", "counter", "Отправлено пиру, байт.", peers(func(p any) any { return field(p, "tx") }))
}
+4
View File
@@ -186,6 +186,9 @@ type Sub struct {
locksMu sync.Mutex
locks map[int]*sync.Mutex // один проход на сервер за раз
peersMu sync.Mutex
peerCalls map[int]*peersCall // последний GET /api/peers по серверу, см. peersOf
sigMu sync.Mutex
seenSigs map[string]time.Time // подпись → когда забыть
@@ -205,6 +208,7 @@ func newSub(a *App) (*Sub, error) {
host: strings.TrimSpace(os.Getenv("SUB_HOST")),
auditOn: strings.ToLower(envOr("AUDIT", "on")) != "off",
locks: map[int]*sync.Mutex{},
peerCalls: map[int]*peersCall{},
seenSigs: map[string]time.Time{},
}
limited := func(what string) func(string) {
+91 -30
View File
@@ -14,6 +14,7 @@ import (
"sort"
"strings"
"sync"
"time"
"github.com/google/uuid"
@@ -104,12 +105,17 @@ func (s *Sub) visible(masterID int64, scope []int) bool {
return true
}
// masterFor — живой (не удаляемый) ключ в пределах области; чужой — как несуществующий.
// masterFor — живой (не удаляемый) ключ в пределах области. Ключ, которого нет или который
// удалён, — 404; ключ, у которого есть сервер вне области API-ключа, — 403: клиент не должен
// принимать его за удалённый и выдавать вместо него второй (так у пользователя появлялся дубль).
func (s *Sub) masterFor(id int64, scope []int) (*masterRow, error) {
m := s.masterByID(s.a.db, id)
if m == nil || !s.visible(m.ID, scope) {
if m == nil {
return nil, fail(404, "Не найден")
}
if !s.visible(m.ID, scope) {
return nil, fail(403, "Ключ вне области API-ключа")
}
return m, nil
}
@@ -278,20 +284,30 @@ func (s *Sub) updateMaster(m *masterRow, body any, scope []int) (jsjson.Object,
devices := s.devicesOf(s.a.db, m.ID)
if err := s.a.tx(func(tx *sql.Tx) error {
for _, sid := range removed {
tx.Exec("DELETE FROM master_servers WHERE master_id = ? AND server_id = ?", m.ID, sid)
if _, err := tx.Exec("DELETE FROM master_servers WHERE master_id = ? AND server_id = ?", m.ID, sid); err != nil {
return err
}
for _, d := range devices {
for _, l := range s.linksOf(tx, d.ID) {
if l.ServerID == sid && l.PeerPub.Valid {
tx.Exec("INSERT OR IGNORE INTO peer_tombstones (server_id, pub_key) VALUES (?, ?)", sid, l.PeerPub.String)
if _, err := tx.Exec("INSERT OR IGNORE INTO peer_tombstones (server_id, pub_key) VALUES (?, ?)", sid, l.PeerPub.String); err != nil {
return err
}
}
tx.Exec("DELETE FROM device_peers WHERE device_id = ? AND server_id = ?", d.ID, sid)
}
if _, err := tx.Exec("DELETE FROM device_peers WHERE device_id = ? AND server_id = ?", d.ID, sid); err != nil {
return err
}
}
}
for _, sid := range added {
tx.Exec("INSERT OR IGNORE INTO master_servers (master_id, server_id) VALUES (?, ?)", m.ID, sid)
if _, err := tx.Exec("INSERT OR IGNORE INTO master_servers (master_id, server_id) VALUES (?, ?)", m.ID, sid); err != nil {
return err
}
for _, d := range devices {
tx.Exec("INSERT OR IGNORE INTO device_peers (device_id, server_id) VALUES (?, ?)", d.ID, sid)
if _, err := tx.Exec("INSERT OR IGNORE INTO device_peers (device_id, server_id) VALUES (?, ?)", d.ID, sid); err != nil {
return err
}
}
}
return nil
@@ -377,15 +393,64 @@ type devStat struct {
lastHS float64
rx, tx float64
background bool
torrent bool
}
// peersTTL — сколько держится ответ GET /api/peers сервера. Вкладка за один проход просит
// устройства каждого ключа, и без этого каждый запрос заново тянул бы пиров со всех серверов.
const peersTTL = 3 * time.Second
type peersCall struct {
done chan struct{} // закрывается, когда ответ получен
at time.Time // нулевое — ответ не годится для повторного использования (ошибка)
peers []any
err error
}
// peersOf — пиры сервера с их статистикой. Параллельные запросы к одному серверу делят один
// вызов; свежий ответ переиспользуется peersTTL, ошибка — нет.
func (s *Sub) peersOf(id int) ([]any, error) {
s.peersMu.Lock()
if c := s.peerCalls[id]; c != nil {
select {
case <-c.done:
if !c.at.IsZero() && time.Since(c.at) < peersTTL {
s.peersMu.Unlock()
return c.peers, c.err
}
default: // уже идёт — дождаться его же
s.peersMu.Unlock()
<-c.done
return c.peers, c.err
}
}
c := &peersCall{done: make(chan struct{})}
s.peerCalls[id] = c
s.peersMu.Unlock()
r, err := s.a.nodes.CtrlFor(id)("GET", "/api/peers", nil)
switch {
case err != nil:
c.err = err
case !ok2xx(r.Status):
c.err = fmt.Errorf("awg-ctrl ответил %d", r.Status)
default:
c.peers = arr(field(r.Data, "peers"))
c.at = time.Now()
}
close(c.done)
return c.peers, c.err
}
// masterDevices — статистика устройства по всем серверам ключа: онлайн, если есть свежий
// handshake хоть на одном; трафик суммируется. servers_ok — где уже стоит его текущий ключ.
func (s *Sub) masterDevices(m *masterRow) ([]any, error) {
// Сервер, который не ответил, не рушит список: его статистики просто нет, а id возвращается
// вторым значением — панель покажет, что данные неполные.
func (s *Sub) masterDevices(m *masterRow) ([]any, []int) {
stats := map[string]*devStat{}
var mu sync.Mutex
var wg sync.WaitGroup
var firstErr error
unreachable := []int{}
for _, id := range s.serverIDsOf(s.a.db, m.ID) {
if !s.a.nodes.IsOnline(id) {
continue
@@ -393,19 +458,14 @@ func (s *Sub) masterDevices(m *masterRow) ([]any, error) {
wg.Add(1)
go func() {
defer wg.Done()
r, err := s.a.nodes.CtrlFor(id)("GET", "/api/peers", nil)
peers, err := s.peersOf(id)
mu.Lock()
defer mu.Unlock()
if err != nil {
if firstErr == nil {
firstErr = err
}
unreachable = append(unreachable, id)
return
}
if !ok2xx(r.Status) {
return
}
for _, p := range arr(field(r.Data, "peers")) {
for _, p := range peers {
pub, _ := field(p, "pub_key").(string)
st := stats[pub]
if st == nil {
@@ -420,13 +480,12 @@ func (s *Sub) masterDevices(m *masterRow) ([]any, error) {
st.tx += numOr(field(p, "tx"), 0)
// Устройство стоит на каждом сервере со своим IP; фоновым его делает любой из них.
st.background = st.background || field(p, "priority") == "background"
st.torrent = st.torrent || truthy(field(p, "torrent"))
}
}()
}
wg.Wait()
if firstErr != nil {
return nil, firstErr
}
sort.Ints(unreachable)
out := []any{}
for _, d := range s.devicesOf(s.a.db, m.ID) {
links := s.linksOf(s.a.db, d.ID)
@@ -435,10 +494,14 @@ func (s *Sub) masterDevices(m *masterRow) ([]any, error) {
st = &devStat{}
}
okN := 0
ips := []any{} // IP устройства на каждом сервере ключа, где он уже выдан
for _, l := range links {
if l.PeerPub.Valid && l.PeerPub.String == d.PubKey && l.IP.Valid && l.IP.String != "" {
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"
if st.background {
@@ -449,10 +512,11 @@ 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: "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: "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, unreachable
}
func (s *Sub) deleteDevice(d *deviceRow) jsjson.Object {
@@ -606,8 +670,8 @@ func (s *Sub) masterKeysRouter() *Router {
if err != nil {
return 0, nil, err
}
list, err := s.masterDevices(m)
return 200, jsjson.Object{{Key: "devices", Val: list}}, err
list, down := s.masterDevices(m)
return 200, jsjson.Object{{Key: "devices", Val: list}, {Key: "unreachable", Val: intsJSON(down)}}, nil
}))
return rt
}
@@ -697,10 +761,7 @@ func (s *Sub) apiRouter(scopeOf func(c *Ctx) []int) *Router {
} else if out, err = withLink(c, m, out); err != nil {
return 0, nil, err
}
list, err := s.masterDevices(m)
if err != nil {
return 0, nil, err
}
list, _ := s.masterDevices(m)
return 200, merge(out, jsjson.Object{{Key: "device_list", Val: list}}), nil
}))
rt.Patch("/:id", aw(func(c *Ctx) (int, any, error) {
@@ -744,8 +805,8 @@ func (s *Sub) apiRouter(scopeOf func(c *Ctx) []int) *Router {
if err != nil {
return 0, nil, err
}
list, err := s.masterDevices(m)
return 200, jsjson.Object{{Key: "devices", Val: list}}, err
list, _ := s.masterDevices(m)
return 200, jsjson.Object{{Key: "devices", Val: list}}, nil
}))
rt.Delete("/:id/devices/:device", aw(func(c *Ctx) (int, any, error) {
d, err := device(c)
+62
View File
@@ -0,0 +1,62 @@
package ui
import (
"errors"
"path/filepath"
"testing"
)
// Ключ, который есть, но выходит за область API-ключа, — 403, а не 404: клиент не должен
// принимать его за удалённый и выдавать вместо него второй.
func TestMasterForScope(t *testing.T) {
dir := t.TempDir()
db, err := openUIDB(filepath.Join(dir, "ui.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
s := &Sub{a: &App{db: db, baseDir: dir}} // baseDir: initSchema кладёт туда panel.id
if err := s.initSchema(); err != nil {
t.Fatal(err)
}
exec := func(q string, args ...any) {
t.Helper()
if _, err := db.Exec(q, args...); err != nil {
t.Fatal(err)
}
}
for id, secret := range map[int]string{1: "a", 2: "b", 3: "c"} {
exec("INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (?, ?, 'k', ?, 1)", id, "u"+secret, []byte(secret))
}
exec("INSERT INTO master_servers (master_id, server_id) VALUES (1, 0), (2, 0), (2, 1), (3, 0)")
exec("UPDATE master_keys SET revoked_at = 1 WHERE id = 3")
status := func(id int64, scope []int) int {
_, err := s.masterFor(id, scope)
var ae *apiError
if err == nil {
return 200
}
if !errors.As(err, &ae) {
t.Fatalf("masterFor(%d, %v): %v", id, scope, err)
}
return ae.status
}
for _, c := range []struct {
name string
id int64
scope []int
want int
}{
{"панель видит всё", 2, nil, 200},
{"ключ в области", 1, []int{0}, 200},
{"сервер ключа вне области", 2, []int{0}, 403},
{"нет такого ключа", 99, []int{0}, 404},
{"удалённый ключ", 3, []int{0}, 404},
{"удалённый ключ в панели", 3, nil, 404},
} {
if got := status(c.id, c.scope); got != c.want {
t.Errorf("%s: %d, ожидали %d", c.name, got, c.want)
}
}
}