- 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
295 lines
10 KiB
Go
295 lines
10 KiB
Go
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
|
||
}
|