Skip to content

Commit 34d65fa

Browse files
committed
feat: add abuse detection with blocklist matching
- Match access-log destinations against FireHOL level 1 and custom IP/CIDR lists - Store matches for 14 days with per-server attribution and Telegram alerts - Add node-side destination ingestion with user-id validation and abuse budget caps - Cache user ID set to avoid repeated scans on node sync
1 parent b471b2c commit 34d65fa

41 files changed

Lines changed: 3470 additions & 45 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,18 @@ Swagger UI; **вебхуки** шлют подписанные HMAC-SHA256 со
264264
Своё название и логотип вместо «РосПанели» — в панели и на странице подписки. Акцентный цвет
265265
перекрашивает весь интерфейс, а **тёмная тема** сама подстраивает текст, статусы и графики.
266266

267+
#### 🛡️ Обнаружение злоупотреблений
268+
269+
Панель сверяет **IP-адреса назначения** из access-лога Xray со списком вредоносных сетей и записывает **только совпадения** — обычный трафик никуда не сохраняется. Нужно это ровно для одного: когда прилетает абуз-жалоба на адрес сервера, понять, чей это был трафик.
270+
271+
Список — **FireHOL level 1**: управляющие серверы ботнетов, атакующие и спам-сети. Отобранный уровень с минимумом ложных срабатываний, без CDN и шаред-хостинга. Рядом — **свой список**
272+
(IP/CIDR), который проверяется первым. Совпадения видны в статистике и в карточке юзера, с привязкой к **серверу**, который выпустил трафик; при превышении дневного порога уходит уведомление в Telegram. Категории, свой список, порог и обновление — на вкладке *Настройки → Блоклисты*.
273+
274+
Проверка идёт по адресу, а не по домену, и это не упрощение. Современные клиенты резолвят DNS
275+
мимо туннеля и шифруют SNI (ECH), поэтому до сервера доезжает голый IP.
276+
277+
Совпадения хранятся **14 дней** — этого хватает на разбор жалобы.
278+
267279
#### 🧰 Эксплуатация и безопасность
268280

269281
**Диагностика** одной кнопкой: процесс Xray, применение конфига, срок TLS, место на диске,

cmd/rospanel/service.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"time"
1616

1717
"github.com/AppsGanin/rospanel/internal/auth"
18+
"github.com/AppsGanin/rospanel/internal/abuse"
1819
"github.com/AppsGanin/rospanel/internal/autobackup"
1920
"github.com/AppsGanin/rospanel/internal/backup"
2021
"github.com/AppsGanin/rospanel/internal/connguard"
@@ -167,6 +168,12 @@ func runServer(dataDir string) {
167168
filepath.Join(dataDir, "opera"))
168169
sup.SetOnAccess(mgr.RecordAccess) // track online status + connection IPs
169170
mgr.StartSysstat(dataDir) // host metrics for the dashboard
171+
// Blocklists for abuse detection. Cached copies load synchronously (fast, local),
172+
// so matching works from the first access-log line; downloads run in background
173+
// and a failure leaves the matcher empty rather than holding up the boot.
174+
abuseStore := abuse.NewStore(filepath.Join(dataDir, "abuse"))
175+
mgr.SetAbuse(abuseStore)
176+
go abuseStore.Run(context.Background())
170177
// The health report needs to tell "off on purpose" from "on, but nft refused it".
171178
mgr.SetConnGuardWanted(connGuardWanted)
172179

@@ -372,6 +379,10 @@ func accessFlushLoop(mgr *core.Manager) {
372379
defer t.Stop()
373380
for range t.C {
374381
safeTick("access flush", mgr.FlushAccess)
382+
// Same cadence and the same reason: recordAbuse only buffers. Separate call
383+
// rather than folded into FlushAccess so a failure in one does not cost the
384+
// other its batch — they write different tables for different purposes.
385+
safeTick("abuse flush", mgr.FlushAbuse)
375386
}
376387
}
377388

@@ -392,6 +403,7 @@ func retentionLoop(mgr *core.Manager) {
392403
mgr.PurgeOldEvents()
393404
mgr.PurgeOldAdminAudit()
394405
mgr.PurgeOldConnections()
406+
mgr.PurgeOldAbuse() // blocklist matches past their (short) window
395407
mgr.PurgeOldTraffic() // per-day traffic history past a year
396408
mgr.PurgeExpiredUsers() // no-op unless the operator set a grace period
397409
mgr.PurgeDeletedNodes() // reclaim node tombstones past their grace window

docs/api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@ traffic rows outlive them).
231231
```json
232232
{ "data": [
233233
{ "node_id": 2, "name": "NL", "up": 46059475, "down": 1488367869 },
234-
{ "node_id": 0, "name": "Этот сервер", "up": 52711616, "down": 3901246326 }
234+
{ "node_id": 0, "name": "Мастер", "up": 52711616, "down": 3901246326 }
235235
] }
236236
```
237237

internal/abuse/config_test.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package abuse
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/http/httptest"
7+
"sync"
8+
"sync/atomic"
9+
"testing"
10+
"time"
11+
)
12+
13+
// TestConfigureTogglesCategories: a disabled category must stop matching, and match
14+
// again once re-enabled — the settings toggle is only real if the live matcher
15+
// follows it.
16+
func TestConfigureTogglesCategories(t *testing.T) {
17+
s := NewStore(t.TempDir())
18+
s.Matcher().SetIP(CatBadIP, []string{"203.0.113.0/24"})
19+
20+
// nil config == all on.
21+
if _, ok := s.Matcher().Match("203.0.113.5"); !ok {
22+
t.Fatal("baseline match failed")
23+
}
24+
25+
// Disable the feed: it stops matching, the custom list still works.
26+
s.Configure(map[Category]bool{CatCustom: true}, "198.51.100.0/24")
27+
if cat, ok := s.Matcher().Match("203.0.113.5"); ok {
28+
t.Fatalf("disabled feed still matched as %q", cat)
29+
}
30+
if cat, ok := s.Matcher().Match("198.51.100.5"); !ok || cat != CatCustom {
31+
t.Fatalf("custom list should still match: %q,%v", cat, ok)
32+
}
33+
34+
// Master off (nothing enabled): nothing matches.
35+
s.Configure(map[Category]bool{}, "198.51.100.0/24")
36+
if _, ok := s.Matcher().Match("198.51.100.5"); ok {
37+
t.Fatal("master-off still matched")
38+
}
39+
}
40+
41+
// TestConfigureCustomList: the operator's list is parsed and takes priority, and
42+
// clears when emptied.
43+
func TestConfigureCustomList(t *testing.T) {
44+
s := NewStore(t.TempDir())
45+
s.Configure(nil, "198.51.100.0/24\n2001:db8::1\n# comment\n\n")
46+
47+
if cat, ok := s.Matcher().Match("198.51.100.7"); !ok || cat != CatCustom {
48+
t.Fatalf("custom CIDR: got %q,%v", cat, ok)
49+
}
50+
if cat, ok := s.Matcher().Match("2001:db8::1"); !ok || cat != CatCustom {
51+
t.Fatalf("custom v6: got %q,%v", cat, ok)
52+
}
53+
if _, ok := s.Matcher().Match("198.51.101.7"); ok {
54+
t.Fatal("address outside the custom CIDR matched")
55+
}
56+
57+
s.Configure(nil, "")
58+
if _, ok := s.Matcher().Match("198.51.100.7"); ok {
59+
t.Fatal("custom entry survived an empty list")
60+
}
61+
}
62+
63+
// TestConfigureDisablingCustom: custom off must stop matching even with content.
64+
func TestConfigureDisablingCustom(t *testing.T) {
65+
s := NewStore(t.TempDir())
66+
s.Configure(map[Category]bool{CatCustom: false}, "198.51.100.0/24")
67+
if _, ok := s.Matcher().Match("198.51.100.7"); ok {
68+
t.Fatal("custom matched while disabled")
69+
}
70+
}
71+
72+
// TestParseCustom: addresses and CIDRs survive; comments, blanks and anything that
73+
// is not an address are skipped rather than poisoning the list.
74+
func TestParseCustom(t *testing.T) {
75+
got := ParseCustom("198.51.100.0/24\n# skip\n\nevil.example\n2001:db8::1\n203.0.113.7 trailing comment\nnot an address")
76+
want := []string{"198.51.100.0/24", "2001:db8::1", "203.0.113.7"}
77+
if len(got) != len(want) {
78+
t.Fatalf("got %v, want %v", got, want)
79+
}
80+
for i := range want {
81+
if got[i] != want[i] {
82+
t.Fatalf("got %v, want %v", got, want)
83+
}
84+
}
85+
}
86+
87+
// TestRefreshIsSingleFlight: the operator's "refresh now" button spawns a goroutine
88+
// per click and the route has no rate limit, so overlapping passes must be dropped.
89+
// Two concurrent passes would fight over the temp files — sweepTempFiles removes
90+
// every .dl-*, including the other pass's in-flight download.
91+
func TestRefreshIsSingleFlight(t *testing.T) {
92+
s := NewStore(t.TempDir())
93+
// Point the feed at a server that blocks until we let it go, so the first pass is
94+
// still running while the second one arrives.
95+
release := make(chan struct{})
96+
var hits atomic.Int32
97+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
98+
hits.Add(1)
99+
<-release
100+
_, _ = w.Write([]byte("203.0.113.0/24\n"))
101+
}))
102+
defer srv.Close()
103+
104+
orig := Feeds
105+
Feeds = []Feed{{Category: CatBadIP, URLs: []string{srv.URL}}}
106+
defer func() { Feeds = orig }()
107+
108+
var wg sync.WaitGroup
109+
wg.Add(1)
110+
go func() { defer wg.Done(); s.Refresh(context.Background(), true) }()
111+
112+
// Wait until the first pass is actually inside the download.
113+
for range 100 {
114+
if hits.Load() == 1 {
115+
break
116+
}
117+
time.Sleep(10 * time.Millisecond)
118+
}
119+
120+
// A second pass while the first is in flight must return immediately, not fetch.
121+
done := make(chan struct{})
122+
go func() { s.Refresh(context.Background(), true); close(done) }()
123+
select {
124+
case <-done:
125+
case <-time.After(2 * time.Second):
126+
t.Fatal("overlapping Refresh blocked instead of being dropped")
127+
}
128+
if n := hits.Load(); n != 1 {
129+
t.Fatalf("overlapping Refresh fetched too: %d requests, want 1", n)
130+
}
131+
132+
close(release)
133+
wg.Wait()
134+
135+
// And once the first finished, a later refresh is allowed again.
136+
s.Refresh(context.Background(), true)
137+
if n := hits.Load(); n != 2 {
138+
t.Fatalf("refresh after completion did not run: %d requests, want 2", n)
139+
}
140+
}

0 commit comments

Comments
 (0)