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
This commit is contained in:
2026-10-17 23:35:30 +03:00
parent cf6af5ce0a
commit c73da602d6
16 changed files with 575 additions and 55 deletions
+34 -4
View File
@@ -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<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);
+4
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); }
+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 ? 'фоновый' : 'обычный'}
+97 -11
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 {
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 (
<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);
+4 -2
View File
@@ -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) {
<span className={`chip chip--${d.online ? 'online' : 'offline'}`}>
<span className="chip-dot" />{d.online ? 'Онлайн' : 'Офлайн'}
</span>
<PriorityChip priority={d.priority} />
<PriorityChip priority={d.priority} torrent={d.torrent} />
<TorrentChip torrent={d.torrent} />
<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>
+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; }