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