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{