tunnel: add ban mitm flag
Kim committed
Mar 25, 2026 at 18:37 UTC
b9427b85bc12ea9bd34fc43edaa0b070cd282081
9 files changed
+70
-7
README.md
+1
-1
@@ -34,7 +34,7 @@ Portal is designed so that tenant TLS terminates on your side rather than at the
34
5. Session keys are derived entirely on your side. The relay provides certificate signatures only and does not receive tenant traffic secrets.
35
6. After the handshake, the relay continues forwarding ciphertext without needing tenant TLS plaintext to keep routing traffic.
36
37
-Portal also checks that the relay is preserving TLS passthrough. The Portal client connects to its own public endpoint and compares TLS exporter values observed on both client-controlled ends. If they differ, Portal treats the relay as a suspected TLS terminator, closes the listener, and bans that relay for the current exposure.
37
+Portal also checks that the relay is preserving TLS passthrough. The Portal client connects to its own public endpoint and compares TLS exporter values observed on both client-controlled ends. If they differ, Portal logs suspected TLS termination by default. You can switch to strict enforcement with `portal expose --ban-mitm`.
38
39
## Components
40
cmd/demo-app/main.go
+5
@@ -33,6 +33,7 @@ func main() {
33
type demoConfig struct {
34
relayURLs string
35
discovery bool
36
+ banMITM bool
37
addr string
38
name string
39
desc string
@@ -48,6 +49,7 @@ func runTCPCommand(args []string) error {
49
fs := utils.NewFlagSet("demo-app", printTCPUsage)
50
utils.StringFlagEnv(fs, &cfg.relayURLs, "relays", "https://gosunuts.xyz", "additional relay API URLs (comma-separated; scheme omitted defaults to https; merged with public registry relays when discovery is enabled)", "RELAYS")
51
utils.BoolFlagEnv(fs, &cfg.discovery, "discovery", true, "include public registry relays and enable discovery", "DISCOVERY")
52
+ utils.BoolFlagEnv(fs, &cfg.banMITM, "ban-mitm", false, "ban relay when the MITM self-probe detects TLS termination", "BAN_MITM")
53
utils.StringFlag(fs, &cfg.addr, "addr", "127.0.0.1:8092", "local demo HTTP listen address (host:port or URL; disable if empty)")
54
utils.StringFlag(fs, &cfg.name, "name", "demo-app", "public hostname prefix (single DNS label)")
55
utils.StringFlag(fs, &cfg.desc, "description", "Portal demo connectivity app", "lease description")
@@ -79,6 +81,7 @@ func runUDPCommand(args []string) error {
81
82
utils.StringFlagEnv(fs, &cfg.relayURLs, "relays", "https://localhost:4017", "additional relay API URLs (comma-separated; scheme omitted defaults to https; merged with public registry relays when discovery is enabled)", "RELAYS")
83
utils.BoolFlagEnv(fs, &cfg.discovery, "discovery", true, "include public registry relays and enable discovery", "DISCOVERY")
84
+ utils.BoolFlagEnv(fs, &cfg.banMITM, "ban-mitm", false, "ban relay when the MITM self-probe detects TLS termination", "BAN_MITM")
85
utils.StringFlag(fs, &cfg.name, "name", "demo-udp", "public hostname prefix (single DNS label)")
86
utils.StringFlag(fs, &cfg.desc, "description", "Portal demo UDP echo service", "lease description")
87
utils.StringFlag(fs, &cfg.tags, "tags", "demo,udp,echo", "comma-separated lease tags")
@@ -133,6 +136,7 @@ func runTCPDemo(ctx context.Context, cfg demoConfig) error {
136
exposure, err := sdk.Expose(ctx, sdk.ExposeConfig{
137
RelayURLs: utils.SplitCSV(cfg.relayURLs),
138
Name: cfg.name,
139
+ BanMITM: cfg.banMITM,
140
Discovery: cfg.discovery,
141
Metadata: types.LeaseMetadata{
142
Description: cfg.desc,
@@ -173,6 +177,7 @@ func runUDPDemo(ctx context.Context, cfg demoConfig) error {
177
RelayURLs: utils.SplitCSV(cfg.relayURLs),
178
Name: cfg.name,
179
UDPEnabled: true,
180
+ BanMITM: cfg.banMITM,
181
Discovery: cfg.discovery,
182
Metadata: types.LeaseMetadata{
183
Description: cfg.desc,
cmd/portal-tunnel/README.md
+3
@@ -37,12 +37,14 @@ portal expose localhost:8080 \
37
- `--name` is optional. When omitted, the CLI generates a name for that run.
38
- `--relays` sets the relay API URLs for that run.
39
- `--discovery=false` disables the public registry seed list and the discovery expansion loop for that run.
40
+- `--ban-mitm` enables strict rejection when the TLS self-probe detects termination in the path.
41
42
Flags:
43
44
```text
45
--relays Portal relay API URLs (comma-separated, https only)
46
--discovery Include public registry relays and discover additional relay bootstraps
47
+--ban-mitm Ban relay when the MITM self-probe detects TLS termination
48
--name Public hostname prefix (single DNS label); auto-generated when omitted
49
--description Service description metadata
50
--tags Service tags metadata (comma-separated)
@@ -80,4 +82,5 @@ Legacy execution compatibility has been removed:
82
- With discovery enabled, the configured relay list starts with `public registry + --relays values` and can expand through relay discovery. With `--discovery=false`, only the explicit relay URLs are used. Published public URLs appear only for relays that have registered successfully.
83
- SDK callers that do not set `ListenerConfig.RetryCount` use infinite retry semantics for each relay.
84
- Tenant TLS is provisioned automatically through the relay keyless signer. The SDK fetches the relay certificate chain and uses `/v1/sign` for remote signing.
85
+- TLS self-probe mismatches log warnings by default. Use `--ban-mitm` to reject relays that terminate tenant TLS.
86
- When the local service is unreachable, the tunnel returns an HTTP 503 page.
cmd/portal-tunnel/main.go
+4
@@ -34,6 +34,7 @@ func main() {
34
type exposeFlags struct {
35
relayCSV string
36
discovery bool
37
+ banMITM bool
38
privateKey string
39
name string
40
desc string
@@ -52,6 +53,7 @@ func runExposeCommand(args []string) error {
53
54
utils.StringFlag(fs, &flags.relayCSV, "relays", "", "Additional Portal relay server API URLs (comma-separated; scheme omitted defaults to https)")
55
utils.BoolFlag(fs, &flags.discovery, "discovery", true, "Include public registry relays and discover additional relay bootstraps")
56
+ utils.BoolFlagEnv(fs, &flags.banMITM, "ban-mitm", true, "Ban relay when the MITM self-probe detects TLS termination", "BAN_MITM")
57
utils.StringFlag(fs, &flags.privateKey, "private-key", "", "Owner private key used to derive a discovery address")
58
utils.StringFlag(fs, &flags.name, "name", "", "Public hostname prefix (single DNS label); auto-generated when omitted")
59
utils.StringFlag(fs, &flags.desc, "description", "", "Service description metadata")
@@ -95,6 +97,7 @@ func runExposeCommand(args []string) error {
97
TargetAddr: flags.targetAddr,
98
UDPAddr: flags.udpAddr,
99
UDPEnabled: flags.udp,
100
+ BanMITM: flags.banMITM,
101
Discovery: flags.discovery,
102
Metadata: types.LeaseMetadata{
103
Description: flags.desc,
@@ -252,6 +255,7 @@ func printExposeUsage(w io.Writer) {
255
"portal expose 3000",
256
"portal expose localhost:8080 --name my-app",
257
"portal expose 3000 --udp --udp-addr 127.0.0.1:5353",
258
+ "portal expose 3000 --ban-mitm",
259
"portal expose 3000 --relays https://portal.example.com --discovery=false",
260
},
261
)
docs/architecture.md
+3
-2
@@ -121,7 +121,7 @@ That distinction matters because `/sdk/connect` stops being ordinary HTTP once h
121
- Entry points can opt out of registry defaults and call `utils.NormalizeRelayURLs` directly when they need explicit relay inputs only
122
- `Listener`: validates one relay URL locally, then starts relay compatibility checks, lease registration, reverse session maintenance, and lease renewal in the background until ready
123
- `api_client.go`: internal relay client for control-plane requests, reverse session dialing, and internal QUIC tunnel setup
124
-- `mitm.go`: tenant-side TLS passthrough self-probe. The SDK opens a probe connection to its own public URL, compares TLS exporter values on both SDK-controlled ends, and logs suspected relay-side TLS termination on mismatch
124
+- `mitm.go`: tenant-side TLS passthrough self-probe. The SDK opens a probe connection to its own public URL, compares TLS exporter values on both SDK-controlled ends, and logs suspected relay-side TLS termination on mismatch; strict callers can opt into relay banning instead
125
- `ListenerConfig.RetryCount <= 0` means retry forever; positive values close the listener after the retry budget is exhausted
126
- `NewListener` callers provide explicit normalized relay URLs
127
- Default exposure flow is `Expose{Discovery: true} -> PublicURLs -> http.Server.Serve(exposure)`, with an opt-out path for explicit relay inputs only
@@ -149,6 +149,7 @@ That distinction matters because `/sdk/connect` stops being ordinary HTTP once h
149
- Returns an HTTP 503 response when the local target is unavailable
150
- `--udp` flag (bool, default `false`): enables UDP relay in addition to TCP
151
- `--udp-addr` flag (string): local UDP target address (`host:port` or port only); required when `--udp` is enabled
152
+- `--ban-mitm` flag (bool, default `false`): when enabled, TLS self-probe mismatches ban the relay for the current exposure instead of only logging
153
- `runUDPBestEffort`: waits for datagram readiness, then calls `proxyExposureDatagrams`
154
- `proxyExposureDatagrams` (`relays.go`): per-flow UDP sockets to local target with idle cleanup; uses `Exposure.SendDatagram()` for the return path
155
- Best-effort UDP — failures logged but do not terminate the TCP tunnel
@@ -178,7 +179,7 @@ Result: the relay decides routing, but tenant TLS termination still happens at t
179
6. If those bytes match a pending nonce, the SDK exports keying material on the server side and compares it with the client-side exporter value.
180
7. Matching exporter values mean the probe observed passthrough for that connection. A mismatch is logged as suspected relay-side TLS termination. A timeout is logged as probe failure, not proof of MITM.
181
181
-Result: this is a detect-only signal. It raises the cost of adaptive relay-side TLS termination, but it does not prove passthrough for every user connection.
182
+Result: this is a detect-only signal by default. It raises the cost of adaptive relay-side TLS termination, but it does not prove passthrough for every user connection. Callers that need stricter behavior can opt into relay banning.
183
184
### UDP/QUIC Datagram Transport
185
sdk/expose.go
+4
@@ -29,6 +29,7 @@ type Exposure struct {
29
UDPAddr string
30
reverseToken string
31
udpEnabled bool
32
+ banMITM bool
33
metadata types.LeaseMetadata
34
ownerAddress string
35
rootCAPEM []byte
@@ -54,6 +55,7 @@ type ExposeConfig struct {
55
UDPAddr string
56
ReverseToken string
57
UDPEnabled bool
58
+ BanMITM bool
59
Discovery bool
60
Metadata types.LeaseMetadata
61
OwnerPrivateKey string
@@ -93,6 +95,7 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
95
UDPAddr: udpAddr,
96
reverseToken: cfg.ReverseToken,
97
udpEnabled: cfg.UDPEnabled,
98
+ banMITM: cfg.BanMITM,
99
metadata: cfg.Metadata.Copy(),
100
ownerAddress: identity.Address,
101
rootCAPEM: append([]byte(nil), cfg.RootCAPEM...),
@@ -443,6 +446,7 @@ func (e *Exposure) newListener(relayURL string) (*Listener, error) {
446
Name: e.name,
447
ReverseToken: e.reverseToken,
448
UDPEnabled: e.udpEnabled,
449
+ BanMITM: e.banMITM,
450
RegisterBootstraps: bootstraps,
451
Metadata: e.metadata.Copy(),
452
RootCAPEM: append([]byte(nil), e.rootCAPEM...),
sdk/listener.go
+10
@@ -25,6 +25,7 @@ type ListenerConfig struct {
25
Name string
26
ReverseToken string
27
UDPEnabled bool
28
+ BanMITM bool
29
Metadata types.LeaseMetadata
30
RootCAPEM []byte
31
DialTimeout time.Duration
@@ -67,6 +68,7 @@ type Listener struct {
68
closeOnce sync.Once
69
registerOnce sync.Once
70
71
+ banMITM bool
72
mu sync.Mutex
73
startupStatus listenerStatus
74
leaseID string
@@ -111,6 +113,7 @@ func NewListener(ctx context.Context, relayURL string, cfg ListenerConfig) (*Lis
113
renewBefore: renewBefore,
114
registerBootstraps: initialBootstraps,
115
metadata: cfg.Metadata.Copy(),
116
+ banMITM: cfg.BanMITM,
117
}
118
l.mitmManager = newMITMManager(listenerCtx, l)
119
l.stream = transport.NewClientStream(readyTarget, handshakeTimeout)
@@ -556,3 +559,10 @@ func (l *Listener) StartupStatus() listenerStatus {
559
defer l.mu.Unlock()
560
return l.startupStatus
561
}
562
+
563
+func (l *Listener) BanMITM() bool {
564
+ if l == nil {
565
+ return false
566
+ }
567
+ return l.banMITM
568
+}
sdk/mitm.go
+9
-4
@@ -241,13 +241,18 @@ func (m *mitmManager) logResult(report MITMProbeReport, err error) {
241
Str("lease_id", report.LeaseID).
242
Msg("tls self-probe timed out before passthrough could be verified")
243
case report.Detected:
244
- log.Warn().
244
+ event := log.Warn().
245
+ Bool("ban_mitm", l.BanMITM()).
246
Str("reason", report.Reason).
247
Str("relay_url", report.RelayURL).
248
Str("public_url", report.PublicURL).
248
- Str("lease_id", report.LeaseID).
249
- Msg("tls termination suspected by self-probe")
250
- l.ban()
249
+ Str("lease_id", report.LeaseID)
250
+ if l.BanMITM() {
251
+ event.Msg("tls termination suspected by self-probe; banning relay")
252
+ l.ban()
253
+ return
254
+ }
255
+ event.Msg("tls termination suspected by self-probe")
256
default:
257
log.Debug().
258
Str("relay_url", report.RelayURL).
sdk/mitm_test.go
+31
@@ -217,6 +217,7 @@ func TestMITMProbeDetectionBansListener(t *testing.T) {
217
},
218
doneCh: doneCh,
219
registered: make(chan struct{}),
220
+ banMITM: true,
221
}
222
listener.mitmManager = newMITMManager(context.Background(), listener)
223
listener.setStartupStatus(listenerStatusReady)
@@ -235,6 +236,36 @@ func TestMITMProbeDetectionBansListener(t *testing.T) {
236
}
237
}
238
239
+func TestMITMProbeDetectionWarnsWithoutBanningListener(t *testing.T) {
240
+ doneCh := make(chan struct{})
241
+ relayURL, err := url.Parse("https://relay.example")
242
+ if err != nil {
243
+ t.Fatalf("url.Parse() error = %v", err)
244
+ }
245
+
246
+ listener := &Listener{
247
+ api: &apiClient{baseURL: relayURL},
248
+ doneCh: doneCh,
249
+ registered: make(chan struct{}),
250
+ banMITM: false,
251
+ }
252
+ listener.mitmManager = newMITMManager(context.Background(), listener)
253
+ listener.setStartupStatus(listenerStatusReady)
254
+
255
+ listener.mitmManager.logResult(MITMProbeReport{
256
+ RelayURL: relayURL.String(),
257
+ Detected: true,
258
+ Reason: types.MITMProbeReasonExporterMismatch,
259
+ }, nil)
260
+
261
+ if status := listener.StartupStatus(); status != listenerStatusReady {
262
+ t.Fatalf("listener status = %q, want %q", status, listenerStatusReady)
263
+ }
264
+ if listener.closed() {
265
+ t.Fatal("listener.closed() = true, want false")
266
+ }
267
+}
268
+
269
func TestMITMProbeDialAddressUsesRelayHostForLocalRelay(t *testing.T) {
270
relayURL, err := url.Parse("https://localhost:4017")
271
if err != nil {