From c73da602d6c8f839038997be59e9d764053e3d31 Mon Sep 17 00:00:00 2001 From: Ivan Vasilev Date: Sat, 17 Oct 2026 23:35:30 +0300 Subject: [PATCH] Block BitTorrent on the server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- awg-ui/mock-server.ts | 38 ++- awg-ui/src/App.css | 4 + awg-ui/src/components/PriorityChip.tsx | 13 +- awg-ui/src/components/ServerSettingsModal.tsx | 108 ++++++- awg-ui/src/components/TorrentChip.tsx | 14 + awg-ui/src/lib/shared.ts | 11 + awg-ui/src/tabs/masterkeys/index.tsx | 6 +- awg-ui/src/tabs/users/index.tsx | 8 +- awg-ui/src/tabs/users/users.css | 4 + internal/ctrl/metrics.go | 7 +- internal/ctrl/routes.go | 20 +- internal/ctrl/server.go | 3 + internal/ctrl/shaping.go | 81 +++-- internal/ctrl/torrent.go | 294 ++++++++++++++++++ internal/ui/metrics.go | 16 + internal/ui/sub_admin.go | 3 + 16 files changed, 575 insertions(+), 55 deletions(-) create mode 100644 awg-ui/src/components/TorrentChip.tsx create mode 100644 internal/ctrl/torrent.go diff --git a/awg-ui/mock-server.ts b/awg-ui/mock-server.ts index 6ceee57..0c213b8 100644 --- a/awg-ui/mock-server.ts +++ b/awg-ui/mock-server.ts @@ -57,7 +57,10 @@ 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 }); 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 +168,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 +182,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 = {}; +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); diff --git a/awg-ui/src/App.css b/awg-ui/src/App.css index df5885c..499ea2e 100644 --- a/awg-ui/src/App.css +++ b/awg-ui/src/App.css @@ -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); } diff --git a/awg-ui/src/components/PriorityChip.tsx b/awg-ui/src/components/PriorityChip.tsx index cd197ad..dacb30d 100644 --- a/awg-ui/src/components/PriorityChip.tsx +++ b/awg-ui/src/components/PriorityChip.tsx @@ -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 ( {bg ? 'фоновый' : 'обычный'} diff --git a/awg-ui/src/components/ServerSettingsModal.tsx b/awg-ui/src/components/ServerSettingsModal.tsx index 7673a0e..fbe1378 100644 --- a/awg-ui/src/components/ServerSettingsModal.tsx +++ b/awg-ui/src/components/ServerSettingsModal.tsx @@ -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(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 { - const s = await apiFetch('PUT', '/api/shaping', token, { - enabled, auto, downMbit: Number(down) || 0, upMbit: Number(up) || 0, - }); - fill(s as ShapingStatus); - showMsg(enabled ? 'Деление канала включено' : 'Деление канала выключено'); + if (shapingDirty) { + const s = await apiFetch('PUT', '/api/shaping', token, { + enabled, auto, downMbit: Number(down) || 0, upMbit: Number(up) || 0, + }); + 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, 'Не удалось сохранить')); } finally { setBusy(false); } }; @@ -70,7 +101,8 @@ export default function ServerSettingsModal({ token, node, onClose, showMsg }: { return (
e.stopPropagation()}> -

Деление канала: {node.name}

+

Настройки сервера: {node.name}

+

Деление канала

{unsupported ? (

@@ -135,11 +167,65 @@ export default function ServerSettingsModal({ token, node, onClose, showMsg }: { )} +


+

Торренты

+ {tUnsupported ? ( +

+ Этот сервер не умеет блокировать торренты: обновите его (install.sh → «Обновить»). +

+ ) : ( + <> + + + {tst?.settings.enabled && ( +
+ + {tst.active ? 'работает' : 'не применено'} + + заблокировано попыток: {tst.hits.toLocaleString('ru-RU')} + {tst.active && !tst.tcp && ( + + TCP не проверяется: на сервере старый nftables. DHT, трекеры и uTP блокируются. + + )} + {tst.error && {tst.error}} + {tst.seen.length > 0 && ( + <> + Пытались качать за последние 10 минут: + + {tst.seen.map(p => ( + + {p.name ? `${p.name} · ${p.ip}` : p.ip} + + ))} + + + )} +
+ )} + + )} + {error &&

{error}

}
- {!unsupported && ( - )} diff --git a/awg-ui/src/components/TorrentChip.tsx b/awg-ui/src/components/TorrentChip.tsx new file mode 100644 index 0000000..dbe31ed --- /dev/null +++ b/awg-ui/src/components/TorrentChip.tsx @@ -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 ( + + торренты + + ); +} diff --git a/awg-ui/src/lib/shared.ts b/awg-ui/src/lib/shared.ts index 5e320bf..b7ac4b9 100644 --- a/awg-ui/src/lib/shared.ts +++ b/awg-ui/src/lib/shared.ts @@ -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); diff --git a/awg-ui/src/tabs/masterkeys/index.tsx b/awg-ui/src/tabs/masterkeys/index.tsx index c8350fd..f3edc32 100644 --- a/awg-ui/src/tabs/masterkeys/index.tsx +++ b/awg-ui/src/tabs/masterkeys/index.tsx @@ -7,6 +7,7 @@ import { apiFetch, copyText, bytes, timeAgo, type NodeInfo, type PageProps, type import { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings } 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,7 +17,7 @@ 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; // на скольких серверах ключа уже стоит его текущий ключ } @@ -284,7 +285,8 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) { {d.online ? 'Онлайн' : 'Офлайн'} - + +
↓{bytes(d.rx)} ↑{bytes(d.tx)} diff --git a/awg-ui/src/tabs/users/index.tsx b/awg-ui/src/tabs/users/index.tsx index 5cd0fd2..0bc0996 100644 --- a/awg-ui/src/tabs/users/index.tsx +++ b/awg-ui/src/tabs/users/index.tsx @@ -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) { : 'никогда'} - + {u.name} {u.key_gen !== serverGen && ( @@ -254,7 +255,8 @@ export default function UsersPage({ token, showMsg }: PageProps) {
{u.ip} - + +
diff --git a/awg-ui/src/tabs/users/users.css b/awg-ui/src/tabs/users/users.css index 9d50ea3..371ae3d 100644 --- a/awg-ui/src/tabs/users/users.css +++ b/awg-ui/src/tabs/users/users.css @@ -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; } diff --git a/internal/ctrl/metrics.go b/internal/ctrl/metrics.go index 74d4817..31d728f 100644 --- a/internal/ctrl/metrics.go +++ b/internal/ctrl/metrics.go @@ -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"` } @@ -139,7 +141,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), s.torrent.Seen(ip)}) } rows.Close() devs, err := s.peersAll() @@ -148,7 +150,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), s.torrent.Seen(p.IP)}) } b.Peers.Total = st.Peers for _, ps := range stats { @@ -163,6 +165,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 } diff --git a/internal/ctrl/routes.go b/internal/ctrl/routes.go index 7eac6a4..44da853 100644 --- a/internal/ctrl/routes.go +++ b/internal/ctrl/routes.go @@ -87,7 +87,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", s.torrent.Seen(ip)}}) } rows.Close() return ok(ob{{"users", list}}) @@ -147,7 +148,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", s.torrent.Seen(p.IP)}}) } return ok(ob{{"peers", list}}) }), @@ -357,6 +358,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 { diff --git a/internal/ctrl/server.go b/internal/ctrl/server.go index 6e505ac..cafd40a 100644 --- a/internal/ctrl/server.go +++ b/internal/ctrl/server.go @@ -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 } diff --git a/internal/ctrl/shaping.go b/internal/ctrl/shaping.go index aba59e0..87c0f32 100644 --- a/internal/ctrl/shaping.go +++ b/internal/ctrl/shaping.go @@ -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{} } diff --git a/internal/ctrl/torrent.go b/internal/ctrl/torrent.go new file mode 100644 index 0000000..9d305d5 --- /dev/null +++ b/internal/ctrl/torrent.go @@ -0,0 +1,294 @@ +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 + tcp bool + lastError string + cacheAt time.Time + cache map[string]bool + cacheHits float64 +} + +func newTorrent(s *Server) *Torrent { return &Torrent{s: s} } + +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.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 +} + +func (t *Torrent) cached() (map[string]bool, float64, bool) { + if !t.Settings().Enabled { + return nil, 0, false + } + t.mu.Lock() + 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 +} + +// Seen — пытался ли этот IP открыть торрент-соединение за последние torrentSeenTTL. +func (t *Torrent) Seen(ip string) bool { + seen, _, _ := t.cached() + return seen[ip] +} + +// 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 +} diff --git a/internal/ui/metrics.go b/internal/ui/metrics.go index 974391e..08f891d 100644 --- a/internal/ui/metrics.go +++ b/internal/ui/metrics.go @@ -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") })) } diff --git a/internal/ui/sub_admin.go b/internal/ui/sub_admin.go index f5b522c..39fd17e 100644 --- a/internal/ui/sub_admin.go +++ b/internal/ui/sub_admin.go @@ -377,6 +377,7 @@ type devStat struct { lastHS float64 rx, tx float64 background bool + torrent bool } // masterDevices — статистика устройства по всем серверам ключа: онлайн, если есть свежий @@ -420,6 +421,7 @@ 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")) } }() } @@ -449,6 +451,7 @@ 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: "torrent", Val: st.torrent}, {Key: "servers_total", Val: len(links)}, {Key: "servers_ok", Val: okN}, }) }