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 {