sdk: ban listeners on MITM detection
Kim committed
Mar 25, 2026 at 15:28 UTC
7152022465f52665a67e0fbb4821c15b7e03481f
7 files changed
+301
-13
sdk/expose.go
+36
@@ -40,6 +40,7 @@ type Exposure struct {
40
mu sync.RWMutex
41
knownRelayURLs []string
42
activeRelayURLs []string
43
+ bannedRelayURLs []string
44
listeners map[string]*Listener
45
starting map[string]struct{}
46
@@ -154,6 +155,17 @@ func (e *Exposure) ActiveRelayURLs() []string {
155
return append([]string(nil), e.activeRelayURLs...)
156
}
157
158
+func (e *Exposure) BannedRelayURLs() []string {
159
+ e.mu.RLock()
160
+ defer e.mu.RUnlock()
161
+
162
+ if len(e.bannedRelayURLs) == 0 {
163
+ return nil
164
+ }
165
+
166
+ return append([]string(nil), e.bannedRelayURLs...)
167
+}
168
+
169
func (e *Exposure) Accept() (net.Conn, error) {
170
select {
171
case <-e.done:
@@ -356,6 +368,7 @@ func (e *Exposure) applyRelayURLs(relayURLs []string, failOnError bool) ([]strin
368
snapshot := append([]string(nil), relayURLs...)
369
370
e.mu.Lock()
371
+ snapshot = utils.FilterRelayURLs(snapshot, e.bannedRelayURLs)
372
existing := make(map[string]struct{}, len(e.knownRelayURLs))
373
for _, relayURL := range e.knownRelayURLs {
374
existing[relayURL] = struct{}{}
@@ -382,6 +395,22 @@ func (e *Exposure) applyRelayURLs(relayURLs []string, failOnError bool) ([]strin
395
return added, nil
396
}
397
398
+func (e *Exposure) banRelayURL(relayURL string) {
399
+ e.mu.Lock()
400
+ e.knownRelayURLs = utils.RemoveRelayURL(e.knownRelayURLs, relayURL)
401
+ e.activeRelayURLs = utils.RemoveRelayURL(e.activeRelayURLs, relayURL)
402
+ e.bannedRelayURLs = utils.AppendUniqueRelayURL(e.bannedRelayURLs, relayURL)
403
+ delete(e.listeners, relayURL)
404
+ delete(e.starting, relayURL)
405
+ bannedRelayURLs := append([]string(nil), e.bannedRelayURLs...)
406
+ e.mu.Unlock()
407
+
408
+ log.Warn().
409
+ Str("relay_url", relayURL).
410
+ Strs("banned_relays", bannedRelayURLs).
411
+ Msg("relay banned by mitm detection")
412
+}
413
+
414
func (e *Exposure) syncListeners(failOnError bool) error {
415
e.mu.Lock()
416
missing := make([]string, 0)
@@ -543,6 +572,11 @@ func (e *Exposure) runListenerAcceptLoop(listener *Listener) {
572
573
relayURL := listener.api.baseURL.String()
574
defer func() {
575
+ if listener.StartupStatus() == listenerStatusBanned {
576
+ e.banRelayURL(relayURL)
577
+ return
578
+ }
579
+
580
e.mu.Lock()
581
if current, ok := e.listeners[relayURL]; ok && current == listener {
582
delete(e.listeners, relayURL)
@@ -734,7 +768,9 @@ func (e *Exposure) monitorStartupCounts() {
768
}
769
770
if firstRun || len(activated) > 0 || len(deactivated) > 0 {
771
+ bannedCount := len(e.BannedRelayURLs())
772
event := log.Info().
773
+ Int("banned", bannedCount).
774
Int("inactive", inactiveCount).
775
Int("ready", readyCount)
776
if len(activated) > 0 {
sdk/expose_test.go
new
+91
@@ -0,0 +1,91 @@
1
+package sdk
2
+
3
+import (
4
+ "net/url"
5
+ "testing"
6
+)
7
+
8
+func TestExposureBanRelayURLMovesRelay(t *testing.T) {
9
+ const (
10
+ relayA = "https://relay-a.example"
11
+ relayB = "https://relay-b.example"
12
+ )
13
+
14
+ relayURL, err := url.Parse(relayA)
15
+ if err != nil {
16
+ t.Fatalf("url.Parse() error = %v", err)
17
+ }
18
+
19
+ listener := &Listener{
20
+ api: &apiClient{baseURL: relayURL},
21
+ startupStatus: listenerStatusBanned,
22
+ }
23
+
24
+ exposure := &Exposure{
25
+ knownRelayURLs: []string{relayA, relayB},
26
+ activeRelayURLs: []string{relayA, relayB},
27
+ bannedRelayURLs: nil,
28
+ listeners: map[string]*Listener{
29
+ relayA: listener,
30
+ relayB: {},
31
+ },
32
+ starting: map[string]struct{}{
33
+ relayA: {},
34
+ },
35
+ }
36
+
37
+ exposure.banRelayURL(relayA)
38
+
39
+ if got := exposure.KnownRelayURLs(); len(got) != 1 || got[0] != relayB {
40
+ t.Fatalf("KnownRelayURLs() = %v, want [%q]", got, relayB)
41
+ }
42
+ if got := exposure.ActiveRelayURLs(); len(got) != 1 || got[0] != relayB {
43
+ t.Fatalf("ActiveRelayURLs() = %v, want [%q]", got, relayB)
44
+ }
45
+ if got := exposure.BannedRelayURLs(); len(got) != 1 || got[0] != relayA {
46
+ t.Fatalf("BannedRelayURLs() = %v, want [%q]", got, relayA)
47
+ }
48
+
49
+ exposure.mu.RLock()
50
+ _, listenerExists := exposure.listeners[relayA]
51
+ _, startingExists := exposure.starting[relayA]
52
+ exposure.mu.RUnlock()
53
+ if listenerExists {
54
+ t.Fatal("banned relay listener still exists in exposure.listeners")
55
+ }
56
+ if startingExists {
57
+ t.Fatal("banned relay still exists in exposure.starting")
58
+ }
59
+}
60
+
61
+func TestExposureApplyRelayURLsSkipsBannedRelay(t *testing.T) {
62
+ const (
63
+ relayA = "https://relay-a.example"
64
+ relayB = "https://relay-b.example"
65
+ )
66
+
67
+ exposure := &Exposure{
68
+ bannedRelayURLs: []string{relayB},
69
+ listeners: map[string]*Listener{
70
+ relayA: {},
71
+ },
72
+ starting: make(map[string]struct{}),
73
+ }
74
+
75
+ added, err := exposure.applyRelayURLs([]string{relayA, relayB}, false)
76
+ if err != nil {
77
+ t.Fatalf("applyRelayURLs() error = %v", err)
78
+ }
79
+ if len(added) != 1 || added[0] != relayA {
80
+ t.Fatalf("added relay urls = %v, want [%q]", added, relayA)
81
+ }
82
+ if got := exposure.KnownRelayURLs(); len(got) != 1 || got[0] != relayA {
83
+ t.Fatalf("KnownRelayURLs() = %v, want [%q]", got, relayA)
84
+ }
85
+ if got := exposure.ActiveRelayURLs(); len(got) != 1 || got[0] != relayA {
86
+ t.Fatalf("ActiveRelayURLs() = %v, want [%q]", got, relayA)
87
+ }
88
+ if got := exposure.BannedRelayURLs(); len(got) != 1 || got[0] != relayB {
89
+ t.Fatalf("BannedRelayURLs() = %v, want [%q]", got, relayB)
90
+ }
91
+}
sdk/listener.go
+17
@@ -45,6 +45,7 @@ type listenerStatus string
45
const (
46
listenerStatusInactive listenerStatus = "inactive"
47
listenerStatusReady listenerStatus = "ready"
48
+ listenerStatusBanned listenerStatus = "banned"
49
)
50
51
type Listener struct {
@@ -62,6 +63,7 @@ type Listener struct {
63
datagram *transport.ClientDatagram
64
65
registered chan struct{}
66
+ banOnce sync.Once
67
closeOnce sync.Once
68
registerOnce sync.Once
69
@@ -534,10 +536,25 @@ func (l *Listener) setStartupStatus(status listenerStatus) {
536
return
537
}
538
l.mu.Lock()
539
+ if l.startupStatus == listenerStatusBanned && status != listenerStatusBanned {
540
+ l.mu.Unlock()
541
+ return
542
+ }
543
l.startupStatus = status
544
l.mu.Unlock()
545
}
546
547
+func (l *Listener) ban() {
548
+ if l == nil {
549
+ return
550
+ }
551
+
552
+ l.banOnce.Do(func() {
553
+ l.setStartupStatus(listenerStatusBanned)
554
+ _ = l.Close()
555
+ })
556
+}
557
+
558
func (l *Listener) StartupStatus() listenerStatus {
559
if l == nil {
560
return listenerStatusInactive
sdk/mitm.go
+1
@@ -246,6 +246,7 @@ func (m *mitmManager) logResult(report MITMProbeReport, err error) {
246
Str("public_url", report.PublicURL).
247
Str("lease_id", report.LeaseID).
248
Msg("tls termination suspected by self-probe")
249
+ l.ban()
250
default:
251
log.Debug().
252
Str("relay_url", report.RelayURL).
sdk/mitm_test.go
+37
@@ -14,6 +14,7 @@ import (
14
"io"
15
"math/big"
16
"net"
17
+ "net/url"
18
"testing"
19
"time"
20
@@ -198,6 +199,42 @@ func TestMITMProbeConnPassesThroughNormalTraffic(t *testing.T) {
199
}
200
}
201
202
+func TestMITMProbeDetectionBansListener(t *testing.T) {
203
+ doneCh := make(chan struct{})
204
+ relayURL, err := url.Parse("https://relay.example")
205
+ if err != nil {
206
+ t.Fatalf("url.Parse() error = %v", err)
207
+ }
208
+
209
+ listener := &Listener{
210
+ api: &apiClient{baseURL: relayURL},
211
+ cancel: func() {
212
+ select {
213
+ case <-doneCh:
214
+ default:
215
+ close(doneCh)
216
+ }
217
+ },
218
+ doneCh: doneCh,
219
+ registered: make(chan struct{}),
220
+ }
221
+ listener.mitmManager = newMITMManager(context.Background(), listener)
222
+ listener.setStartupStatus(listenerStatusReady)
223
+
224
+ listener.mitmManager.logResult(MITMProbeReport{
225
+ RelayURL: relayURL.String(),
226
+ Detected: true,
227
+ Reason: types.MITMProbeReasonExporterMismatch,
228
+ }, nil)
229
+
230
+ if status := listener.StartupStatus(); status != listenerStatusBanned {
231
+ t.Fatalf("listener status = %q, want %q", status, listenerStatusBanned)
232
+ }
233
+ if !listener.closed() {
234
+ t.Fatal("listener.closed() = false, want true")
235
+ }
236
+}
237
+
238
func newMITMProbeTLSPair(t *testing.T) (*tls.Conn, *tls.Conn) {
239
t.Helper()
240
utils/utils.go
+73
-13
@@ -191,33 +191,93 @@ func NormalizeRelayURLs(inputs ...string) ([]string, error) {
191
return uniqueURLs(out), nil
192
}
193
194
-func MergeRelayURLs(current, excluded, inputs []string) ([]string, error) {
195
- merged, err := NormalizeRelayURLs(append(append([]string(nil), current...), inputs...)...)
196
- if err != nil {
197
- return nil, err
194
+func FilterRelayURLs(inputs, excluded []string) []string {
195
+ if len(inputs) == 0 {
196
+ return nil
197
}
198
if len(excluded) == 0 {
200
- return merged, nil
201
- }
202
-
203
- excluded, err = NormalizeRelayURLs(excluded...)
204
- if err != nil {
205
- return nil, err
199
+ return append([]string(nil), inputs...)
200
}
201
202
skip := make(map[string]struct{}, len(excluded))
203
for _, input := range excluded {
204
+ input = strings.TrimSpace(input)
205
+ if input == "" {
206
+ continue
207
+ }
208
skip[input] = struct{}{}
209
}
210
213
- filtered := make([]string, 0, len(merged))
214
- for _, input := range merged {
211
+ filtered := make([]string, 0, len(inputs))
212
+ for _, input := range inputs {
213
+ input = strings.TrimSpace(input)
214
+ if input == "" {
215
+ continue
216
+ }
217
if _, ok := skip[input]; ok {
218
continue
219
}
220
filtered = append(filtered, input)
221
}
220
- return filtered, nil
222
+ if len(filtered) == 0 {
223
+ return nil
224
+ }
225
+ return filtered
226
+}
227
+
228
+func RemoveRelayURL(inputs []string, target string) []string {
229
+ if len(inputs) == 0 {
230
+ return nil
231
+ }
232
+
233
+ target = strings.TrimSpace(target)
234
+ if target == "" {
235
+ return append([]string(nil), inputs...)
236
+ }
237
+
238
+ filtered := make([]string, 0, len(inputs))
239
+ for _, input := range inputs {
240
+ input = strings.TrimSpace(input)
241
+ if input == "" || input == target {
242
+ continue
243
+ }
244
+ filtered = append(filtered, input)
245
+ }
246
+ if len(filtered) == 0 {
247
+ return nil
248
+ }
249
+ return filtered
250
+}
251
+
252
+func AppendUniqueRelayURL(inputs []string, target string) []string {
253
+ target = strings.TrimSpace(target)
254
+ if target == "" {
255
+ return append([]string(nil), inputs...)
256
+ }
257
+
258
+ for _, input := range inputs {
259
+ if strings.TrimSpace(input) == target {
260
+ return append([]string(nil), inputs...)
261
+ }
262
+ }
263
+ return append(append([]string(nil), inputs...), target)
264
+}
265
+
266
+func MergeRelayURLs(current, excluded, inputs []string) ([]string, error) {
267
+ merged, err := NormalizeRelayURLs(append(append([]string(nil), current...), inputs...)...)
268
+ if err != nil {
269
+ return nil, err
270
+ }
271
+ if len(excluded) == 0 {
272
+ return merged, nil
273
+ }
274
+
275
+ excluded, err = NormalizeRelayURLs(excluded...)
276
+ if err != nil {
277
+ return nil, err
278
+ }
279
+
280
+ return FilterRelayURLs(merged, excluded), nil
281
}
282
283
func uniqueURLs(inputs []string) []string {
utils/utils_test.go
+46
@@ -28,6 +28,52 @@ func TestNormalizeRelayURLs(t *testing.T) {
28
}
29
}
30
31
+func TestFilterRelayURLs(t *testing.T) {
32
+ t.Parallel()
33
+
34
+ got := FilterRelayURLs(
35
+ []string{"https://relay-a.example", "https://relay-b.example"},
36
+ []string{"https://relay-b.example"},
37
+ )
38
+
39
+ want := []string{"https://relay-a.example"}
40
+ if !reflect.DeepEqual(got, want) {
41
+ t.Fatalf("FilterRelayURLs() = %v, want %v", got, want)
42
+ }
43
+}
44
+
45
+func TestRemoveRelayURL(t *testing.T) {
46
+ t.Parallel()
47
+
48
+ got := RemoveRelayURL(
49
+ []string{"https://relay-a.example", "https://relay-b.example"},
50
+ "https://relay-a.example",
51
+ )
52
+
53
+ want := []string{"https://relay-b.example"}
54
+ if !reflect.DeepEqual(got, want) {
55
+ t.Fatalf("RemoveRelayURL() = %v, want %v", got, want)
56
+ }
57
+}
58
+
59
+func TestAppendUniqueRelayURL(t *testing.T) {
60
+ t.Parallel()
61
+
62
+ got := AppendUniqueRelayURL(
63
+ []string{"https://relay-a.example"},
64
+ "https://relay-b.example",
65
+ )
66
+ want := []string{"https://relay-a.example", "https://relay-b.example"}
67
+ if !reflect.DeepEqual(got, want) {
68
+ t.Fatalf("AppendUniqueRelayURL() = %v, want %v", got, want)
69
+ }
70
+
71
+ got = AppendUniqueRelayURL(got, "https://relay-b.example")
72
+ if !reflect.DeepEqual(got, want) {
73
+ t.Fatalf("AppendUniqueRelayURL() dedupe = %v, want %v", got, want)
74
+ }
75
+}
76
+
77
func TestParseCIDRs(t *testing.T) {
78
t.Parallel()
79