feat: add multi-hop relay support with depth configuration in portal CLI
Kim committed
Apr 23, 2026 at 18:56 UTC
a4835cf2b6945f532fa7db18809cf71de0948271
9 files changed
+158
-49
AGENTS.md
+2
-2
@@ -9,7 +9,7 @@ Architecture, product behavior, and design rationale belong in `docs/architectur
9
- Prefer a single stable contract with one real owner.
10
- Prefer local simplicity over premature or speculative abstraction.
11
- Add indirection only when it removes real coupling or protects a real boundary.
12
-- Tests should protect stable contracts and invariants, not drive the spec.
12
+- Tests should protect real stable contracts and invariants, not drive the spec or exist only for regression prevention.
13
14
## Project Principles
15
@@ -30,4 +30,4 @@ Architecture, product behavior, and design rationale belong in `docs/architectur
30
- CI commands: `make vet`, `make lint`, `make test`, `make vuln`.
31
- `make tidy` is local maintenance, not a CI requirement.
32
- Run tests only when explicitly requested.
33
-- If verification seems necessary, ask before running it.
\ No newline at end of file
33
+- If verification seems necessary, ask before running it.
cmd/portal-tunnel/README.md
+4
@@ -72,6 +72,8 @@ portal expose --name myapp \
72
- Routed HTTP mode automatically forwards `X-Forwarded-*`, rewrites upstream `Location` redirects back to the public route path, and strips loopback cookie domains while remapping cookie paths to the mounted route prefix.
73
- `--name` is optional. When omitted, the CLI generates a name for that run.
74
- `--relays` adds explicit relay API URLs for that run. Explicit relays are always kept connected and are not counted against `--max-active-relays`.
75
+- `--multi-hop` sets one ordered multi-hop relay path for that run.
76
+- `--multi-hop-depth` automatically selects one multi-hop relay path with that hop count.
77
- `--discovery=false` disables the public registry seed list and the runtime relay discovery expansion loop for that run. With `--discovery=false`, only the explicit `--relays` values are used.
78
- `--ban-mitm` enables strict rejection when the TLS self-probe detects termination in the path.
79
- `--tcp` requests a dedicated TCP port on the relay for raw TCP services that do not use TLS (e.g., Minecraft, game servers).
@@ -81,6 +83,8 @@ Flags:
83
84
```text
85
--relays Portal relay API URLs (comma-separated, https only)
86
+--multi-hop Ordered multi-hop relay API URLs, comma-separated
87
+--multi-hop-depth Automatically select one multi-hop route with this hop count; 0 or 1 disables multi-hop
88
--discovery Include public registry relays and discover additional relay bootstraps
89
--max-active-relays Maximum number of auto-selected relays; explicit --relays are always included
90
--ban-mitm Ban relay when the MITM self-probe detects TLS termination
cmd/portal-tunnel/main.go
+4
@@ -60,6 +60,7 @@ type exposeFlags struct {
60
udpAddr string
61
tcp bool
62
maxActiveRelays int
63
+ multiHopDepth int
64
}
65
66
func runExposeCommand(args []string) error {
@@ -85,6 +86,7 @@ func runExposeCommand(args []string) error {
86
utils.StringFlagEnv(fs, &flags.udpAddr, "udp-addr", "", "Local UDP target address for relayed datagrams (host:port or port only); defaults to the target when --udp is enabled", "UDP_ADDR")
87
utils.BoolFlagEnv(fs, &flags.tcp, "tcp", false, "Request a dedicated TCP port on the relay for raw TCP services (no TLS; e.g., Minecraft, game servers)", "TCP_ENABLED")
88
utils.IntFlagEnv(fs, &flags.maxActiveRelays, "max-active-relays", 3, nil, "Maximum number of auto-selected relays to keep connected; explicit --relays are always included", "MAX_ACTIVE_RELAYS")
89
+ utils.IntFlagEnv(fs, &flags.multiHopDepth, "multi-hop-depth", 0, nil, "Automatically select one multi-hop route with this hop count; 0 or 1 disables multi-hop", "MULTI_HOP_DEPTH")
90
91
if err := utils.ParseFlagSet(fs, args, printExposeUsage); err != nil {
92
if errors.Is(err, flag.ErrHelp) {
@@ -124,6 +126,7 @@ func runExposeCommand(args []string) error {
126
UDPEnabled: flags.udp,
127
TCPEnabled: flags.tcp,
128
MultiHop: utils.SplitCSV(flags.multiHopCSV),
129
+ MultiHopDepth: flags.multiHopDepth,
130
BanMITM: flags.banMITM,
131
MaxActiveRelays: flags.maxActiveRelays,
132
Metadata: types.LeaseMetadata{
@@ -308,6 +311,7 @@ func printExposeUsage(w io.Writer) {
311
"portal expose 3000 --ban-mitm",
312
"portal expose 3000 --relays https://portal.example.com --discovery=false",
313
"portal expose 3000 --multi-hop https://entry.example.com,https://transit.example.com,https://exit.example.com",
314
+ "portal expose 3000 --multi-hop-depth 3",
315
},
316
)
317
}
docs/src/routes/cli-reference/+page.md
+14
@@ -58,6 +58,8 @@ Instead of a positional target, you can use `--http-route` for multi-service rou
58
|------|------|---------|-------------|
59
| `--relays` | string | _(registry)_ | Portal relay API URLs (comma-separated, https only) |
60
| `--discovery` | bool | `true` | Include public registry relays and discover additional bootstraps |
61
+| `--multi-hop` | string | | Ordered multi-hop relay API URLs, comma-separated |
62
+| `--multi-hop-depth` | int | `0` | Automatically select one multi-hop route with this hop count; 0 or 1 disables multi-hop |
63
| `--max-active-relays` | int | `3` | Maximum auto-selected relays to keep connected; explicit relays are always included |
64
| `--ban-mitm` | bool | `true` | Ban relay when the MITM self-probe detects TLS termination |
65
| `--identity-path` | string | `./identity.json` | Identity JSON file path; created automatically when missing |
@@ -113,6 +115,18 @@ Disable relay discovery:
115
portal expose 3000 --relays https://portal.example.com --discovery=false
116
```
117
118
+Explicit multi-hop route:
119
+
120
+```bash
121
+portal expose 3000 --multi-hop https://entry.example.com,https://transit.example.com,https://exit.example.com
122
+```
123
+
124
+Automatic multi-hop route:
125
+
126
+```bash
127
+portal expose 3000 --multi-hop-depth 3
128
+```
129
+
130
Warning-only MITM mode:
131
132
```bash
docs/src/routes/configuration/+page.md
+2
@@ -95,6 +95,8 @@ The `portal expose` subcommand accepts the following flags. Flags that read from
95
|------|---------|------|---------|-------------|
96
| `--relays` | | string | _(registry)_ | Additional Portal relay server API URLs (comma-separated; scheme omitted defaults to https) |
97
| `--discovery` | | bool | `true` | Include public registry relays and discover additional relay bootstraps |
98
+| `--multi-hop` | `MULTI_HOP` | string | | Ordered multi-hop relay API URLs, comma-separated |
99
+| `--multi-hop-depth` | `MULTI_HOP_DEPTH` | int | `0` | Automatically select one multi-hop route with this hop count; 0 or 1 disables multi-hop |
100
| `--max-active-relays` | `MAX_ACTIVE_RELAYS` | int | `3` | Maximum auto-selected relays to keep connected; explicit relays are always included |
101
| `--ban-mitm` | `BAN_MITM` | bool | `true` | Ban relay when the MITM self-probe detects TLS termination |
102
portal/discovery/mols.go
+65
-22
@@ -195,30 +195,12 @@ func (p MOLSRelayPolicy) OnBanned(state RelayState) RelayState {
195
return state
196
}
197
198
-func (p MOLSRelayPolicy) SelectPriority(states []RelayState, clientState ClientState) []string {
199
- selected := p.SelectAggregate(states)
200
- if len(selected) == 0 {
198
+func (p MOLSRelayPolicy) rankRelayPool(autoPool []RelayState, localAddress string) []string {
199
+ if len(autoPool) == 0 {
200
return nil
201
}
202
204
- explicit := make([]string, 0)
205
- autoPool := make([]RelayState, 0, len(selected))
206
- for _, state := range selected {
207
- if clientState.RequireUDP && state.hasObservedDescriptor() && !state.Descriptor.SupportsUDP {
208
- continue
209
- }
210
- if clientState.RequireTCP && state.hasObservedDescriptor() && !state.Descriptor.SupportsTCP {
211
- continue
212
- }
213
- relayURL := state.Descriptor.APIHTTPSAddr
214
- if slices.Contains(clientState.ExplicitRelayURLs, relayURL) {
215
- explicit = append(explicit, relayURL)
216
- continue
217
- }
218
- autoPool = append(autoPool, state)
219
- }
220
-
221
- ingressIdx := hashToGF64(clientState.LocalAddress)
203
+ ingressIdx := hashToGF64(localAddress)
204
avgRTT, cv := molsRTTStats(autoPool)
205
congested := avgRTT > molsCongestionRTTThreshold
206
nonLinear := cv > molsCVThreshold
@@ -294,10 +276,71 @@ func (p MOLSRelayPolicy) SelectPriority(states []RelayState, clientState ClientS
276
}
277
278
autoURLs := append(rank(active), rank(fallbacks)...)
279
+ if len(autoURLs) == 0 {
280
+ return nil
281
+ }
282
+ return autoURLs
283
+}
284
+
285
+func (p MOLSRelayPolicy) SelectPriority(states []RelayState, clientState ClientState) []string {
286
+ selected := p.SelectAggregate(states)
287
+ if len(selected) == 0 {
288
+ return nil
289
+ }
290
+
291
+ explicit := make([]string, 0)
292
+ autoPool := make([]RelayState, 0, len(selected))
293
+ for _, state := range selected {
294
+ if clientState.RequireUDP && state.hasObservedDescriptor() && !state.Descriptor.SupportsUDP {
295
+ continue
296
+ }
297
+ if clientState.RequireTCP && state.hasObservedDescriptor() && !state.Descriptor.SupportsTCP {
298
+ continue
299
+ }
300
+
301
+ relayURL := state.Descriptor.APIHTTPSAddr
302
+ if slices.Contains(clientState.ExplicitRelayURLs, relayURL) {
303
+ explicit = append(explicit, relayURL)
304
+ continue
305
+ }
306
+ autoPool = append(autoPool, state)
307
+ }
308
309
+ autoURLs := p.rankRelayPool(autoPool, clientState.LocalAddress)
310
if clientState.MaxActiveRelays > 0 && len(autoURLs) > clientState.MaxActiveRelays {
311
autoURLs = autoURLs[:clientState.MaxActiveRelays]
312
}
301
-
313
return append(explicit, autoURLs...)
314
}
315
+
316
+func (p MOLSRelayPolicy) SelectMultiHop(states []RelayState, clientState ClientState) []string {
317
+ if clientState.MultiHopDepth <= 1 {
318
+ return nil
319
+ }
320
+
321
+ selected := p.SelectAggregate(states)
322
+ if len(selected) == 0 {
323
+ return nil
324
+ }
325
+
326
+ now := time.Now().UTC()
327
+ autoPool := make([]RelayState, 0, len(selected))
328
+ for _, state := range selected {
329
+ if clientState.RequireUDP && state.hasObservedDescriptor() && !state.Descriptor.SupportsUDP {
330
+ continue
331
+ }
332
+ if clientState.RequireTCP && state.hasObservedDescriptor() && !state.Descriptor.SupportsTCP {
333
+ continue
334
+ }
335
+ if !state.hasObservedDescriptor() || !state.Descriptor.ExpiresAt.After(now) || !state.Descriptor.HasOverlayPeer() {
336
+ continue
337
+ }
338
+ autoPool = append(autoPool, state)
339
+ }
340
+
341
+ multiHop := p.rankRelayPool(autoPool, clientState.LocalAddress)
342
+ if len(multiHop) > clientState.MultiHopDepth {
343
+ multiHop = multiHop[:clientState.MultiHopDepth]
344
+ }
345
+ return multiHop
346
+}
portal/discovery/relayset.go
+12
@@ -230,6 +230,18 @@ func (s *RelaySet) PriorityRelays(clientState ClientState) []string {
230
return policy.SelectPriority(states, clientState)
231
}
232
233
+func (s *RelaySet) PriorityMultiHop(clientState ClientState) []string {
234
+ s.mu.RLock()
235
+ states := make([]RelayState, 0, len(s.relays))
236
+ for _, state := range s.relays {
237
+ states = append(states, state)
238
+ }
239
+ policy := s.policy
240
+ s.mu.RUnlock()
241
+
242
+ return policy.SelectMultiHop(states, clientState)
243
+}
244
+
245
func (s *RelaySet) OverlayPeerStates() []RelayState {
246
now := time.Now().UTC()
247
s.mu.RLock()
portal/discovery/relaystate.go
+2
-3
@@ -56,13 +56,12 @@ func (state RelayState) hasObservedDescriptor() bool {
56
}
57
58
type ClientState struct {
59
- ActiveRelayURLs []string
59
ExplicitRelayURLs []string
60
MaxActiveRelays int
61
+ MultiHopDepth int
62
RequireUDP bool
63
RequireTCP bool
64
// LocalAddress is the ingress identity address used by MOLSRelayPolicy to
65
- // derive a deterministic row index into the GF(64) MOLS grid. When empty
66
- // the policy falls back to index 0, which remains stable across calls.
65
+ // derive a deterministic row index into the GF(64) MOLS grid.
66
LocalAddress string
67
}
sdk/expose.go
+53
-22
@@ -32,6 +32,7 @@ type Exposure struct {
32
udpEnabled bool
33
tcpEnabled bool
34
multiHop []string
35
+ multiHopDepth int
36
banMITM bool
37
maxActiveRelays int
38
metadata types.LeaseMetadata
@@ -48,8 +49,9 @@ type Exposure struct {
49
}
50
51
type ExposeConfig struct {
51
- RelayURLs []string
52
- Discovery bool
52
+ RelayURLs []string
53
+ Discovery bool
54
+
55
IdentityPath string
56
IdentityJSON string
57
Name string
@@ -59,7 +61,10 @@ type ExposeConfig struct {
61
TCPEnabled bool
62
// MultiHop is the caller-selected ordered relay URL path. The first URL is
63
// the public entry relay and the last URL is the exit relay the SDK registers with.
62
- MultiHop []string
64
+ MultiHop []string
65
+ // MultiHopDepth selects one automatic multi-hop route when >= 2. Values 0
66
+ // and 1 keep the automatic route selector in single-hop relay pool mode.
67
+ MultiHopDepth int
68
BanMITM bool
69
MaxActiveRelays int
70
Metadata types.LeaseMetadata
@@ -74,28 +79,38 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
79
}
80
var multiHop []string
81
for _, input := range cfg.MultiHop {
77
- for _, part := range utils.SplitCSV(input) {
78
- relayURL, err := utils.NormalizeRelayURL(part)
79
- if err != nil {
80
- return nil, fmt.Errorf("normalize multi-hop relay url: %w", err)
81
- }
82
- if slices.Contains(multiHop, relayURL) {
83
- return nil, fmt.Errorf("multi-hop relay url repeated: %s", relayURL)
84
- }
85
- multiHop = append(multiHop, relayURL)
82
+ relayURL, err := utils.NormalizeRelayURL(input)
83
+ if err != nil {
84
+ return nil, fmt.Errorf("normalize multi-hop relay url: %w", err)
85
+ }
86
+ if slices.Contains(multiHop, relayURL) {
87
+ return nil, fmt.Errorf("multi-hop relay url repeated: %s", relayURL)
88
}
89
+ multiHop = append(multiHop, relayURL)
90
}
91
if len(multiHop) == 1 {
92
return nil, errors.New("multi-hop requires at least entry and exit relay urls")
93
}
91
- if len(multiHop) > 0 && (cfg.UDPEnabled || cfg.TCPEnabled) {
94
+ if cfg.MultiHopDepth < 0 {
95
+ return nil, errors.New("multi-hop-depth cannot be negative")
96
+ }
97
+ if len(multiHop) > 0 && cfg.MultiHopDepth > 1 {
98
+ return nil, errors.New("explicit --multi-hop cannot be combined with automatic --multi-hop-depth")
99
+ }
100
+ if (len(multiHop) > 0 || cfg.MultiHopDepth > 1) && (cfg.UDPEnabled || cfg.TCPEnabled) {
101
return nil, errors.New("multi-hop currently supports only the default SNI TLS stream transport")
102
}
103
+
104
var listenerRelayURLs []string
105
var relaySetURLs []string
106
if len(multiHop) > 0 {
107
listenerRelayURLs = []string{multiHop[len(multiHop)-1]}
108
relaySetURLs = append([]string(nil), multiHop...)
109
+ } else if cfg.MultiHopDepth > 1 {
110
+ relaySetURLs, err = utils.ResolvePortalRelayURLs(ctx, explicitRelayURLs, cfg.Discovery)
111
+ if err != nil {
112
+ return nil, err
113
+ }
114
} else {
115
listenerRelayURLs, err = utils.ResolvePortalRelayURLs(ctx, explicitRelayURLs, cfg.Discovery)
116
if err != nil {
@@ -141,16 +156,17 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
156
udpEnabled: cfg.UDPEnabled,
157
tcpEnabled: cfg.TCPEnabled,
158
multiHop: multiHop,
159
+ multiHopDepth: cfg.MultiHopDepth,
160
banMITM: cfg.BanMITM,
161
maxActiveRelays: cfg.MaxActiveRelays,
162
metadata: cfg.Metadata,
147
- accepted: make(chan net.Conn, max(len(listenerRelayURLs)*defaultReadyTarget*2, 1)),
148
- datagrams: make(chan types.DatagramFrame, max(len(listenerRelayURLs)*32, 1)),
163
+ accepted: make(chan net.Conn, max(initialRouteCapacity(listenerRelayURLs, cfg.MultiHopDepth)*defaultReadyTarget*2, 1)),
164
+ datagrams: make(chan types.DatagramFrame, max(initialRouteCapacity(listenerRelayURLs, cfg.MultiHopDepth)*32, 1)),
165
relaySet: discovery.NewRelaySet(relaySetURLs),
150
- relayListeners: make(map[string]*listener, len(listenerRelayURLs)),
166
+ relayListeners: make(map[string]*listener, initialRouteCapacity(listenerRelayURLs, cfg.MultiHopDepth)),
167
}
168
153
- if len(multiHop) > 0 {
169
+ if len(multiHop) > 0 || cfg.MultiHopDepth > 1 {
170
refresher := discovery.NewRefresher(exposure.relaySet, nil)
171
if err := refresher.Refresh(ctx, nil); err != nil {
172
_ = exposure.Close()
@@ -158,14 +174,14 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
174
}
175
}
176
161
- if len(listenerRelayURLs) > 0 {
177
+ if len(listenerRelayURLs) > 0 || cfg.MultiHopDepth > 1 {
178
if err := exposure.reconcileRelayListeners(true); err != nil {
179
_ = exposure.Close()
180
return nil, err
181
}
182
}
183
168
- if cfg.Discovery || len(multiHop) > 0 {
184
+ if cfg.Discovery || len(multiHop) > 0 || cfg.MultiHopDepth > 1 {
185
go exposure.runDiscoveryLoop(exposureCtx)
186
}
187
@@ -177,6 +193,13 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
193
return exposure, nil
194
}
195
196
+func initialRouteCapacity(listenerRelayURLs []string, multiHopDepth int) int {
197
+ if multiHopDepth > 1 {
198
+ return 1
199
+ }
200
+ return len(listenerRelayURLs)
201
+}
202
+
203
func (e *Exposure) ActiveRelayURLs() []string {
204
e.listenerMu.RLock()
205
defer e.listenerMu.RUnlock()
@@ -411,9 +434,17 @@ func (e *Exposure) reconcileRelayListeners(failOnError bool) error {
434
if len(e.multiHop) > 0 {
435
listenerRelayURLs = []string{e.multiHop[len(e.multiHop)-1]}
436
multiHop = append([]string(nil), e.multiHop...)
437
+ } else if e.multiHopDepth > 1 {
438
+ multiHop = e.relaySet.PriorityMultiHop(discovery.ClientState{
439
+ MultiHopDepth: e.multiHopDepth,
440
+ LocalAddress: e.identity.Address,
441
+ })
442
+ if len(multiHop) < e.multiHopDepth {
443
+ return fmt.Errorf("multi-hop-depth %d requires %d overlay relay candidates, got %d", e.multiHopDepth, e.multiHopDepth, len(multiHop))
444
+ }
445
+ listenerRelayURLs = []string{multiHop[len(multiHop)-1]}
446
} else {
447
listenerRelayURLs = e.relaySet.PriorityRelays(discovery.ClientState{
416
- ActiveRelayURLs: e.ActiveRelayURLs(),
448
ExplicitRelayURLs: append([]string(nil), e.explicitRelays...),
449
MaxActiveRelays: e.maxActiveRelays,
450
RequireUDP: e.udpEnabled,
@@ -426,7 +457,7 @@ func (e *Exposure) reconcileRelayListeners(failOnError bool) error {
457
staleRelayListeners := make(map[string]*listener)
458
removedRelayURLs := make([]string, 0)
459
for relayURL, listener := range e.relayListeners {
429
- if slices.Contains(listenerRelayURLs, relayURL) {
460
+ if slices.Contains(listenerRelayURLs, relayURL) && slices.Equal(listener.multiHop, multiHop) {
461
continue
462
}
463
staleRelayListeners[relayURL] = listener
@@ -457,7 +488,7 @@ func (e *Exposure) reconcileRelayListeners(failOnError bool) error {
488
}
489
for _, relayURL := range missingRelayURLs {
490
retryCount := 10
460
- if len(e.multiHop) > 0 || slices.Contains(e.explicitRelays, relayURL) {
491
+ if len(multiHop) > 0 || slices.Contains(e.explicitRelays, relayURL) {
492
retryCount = 0
493
}
494
listener, err := newListener(context.Background(), relayURL, listenerConfig{