tunnel: support multi relays

Kim committed Mar 6, 2026 at 16:20 UTC 9d8122d67355404ceeea96c1174c88cd3d2a60c7
4 files changed +352 -198
cmd/portal-tunnel/README.md
+4 -3
@@ -6,7 +6,7 @@ Portal-tunnel connects a local service to a Portal relay with the legacy CLI sha
6
7 ```bash
8 ./portal-tunnel --host localhost:8080 \
9 - --relay https://portal.example.com \
9 + --relays https://portal.example.com \
10 --name myapp \
11 --description "Service description" \
12 --tags tag1,tag2 \
@@ -17,7 +17,7 @@ Portal-tunnel connects a local service to a Portal relay with the legacy CLI sha
17 ## Flags
18
19 ```text
20 ---relay Portal relay server API URLs (comma-separated, https only) [env: RELAYS]
20 +--relays Portal relay server API URLs (comma-separated, https only) [env: RELAYS]
21 --host Target host to proxy to (host:port or URL) [env: APP_HOST]
22 --name Service name [env: APP_NAME]
23 --description Service description metadata [env: APP_DESCRIPTION]
@@ -29,6 +29,7 @@ Portal-tunnel connects a local service to a Portal relay with the legacy CLI sha
29
30 ## Notes
31
32 -- The current runtime accepts multiple relay URLs but uses the first one.
32 +- Multiple relay URLs are registered independently. Each relay gets its own lease ID and public URLs.
33 +- Startup is fail-fast: if any configured relay cannot register, the tunnel exits instead of partially publishing.
34 - Tenant TLS is provisioned automatically through the relay keyless signer. The SDK fetches the relay certificate chain and uses `/v1/sign` for remote signing.
35 - When the local service is unreachable, the tunnel returns an HTTP 503 page.
cmd/portal-tunnel/main.go
+37 -192
@@ -5,12 +5,10 @@ import (
5 "errors"
6 "flag"
7 "fmt"
8 - "io"
9 - "net"
8 "os"
9 "os/signal"
12 - "strings"
10 "sync"
11 + "sync/atomic"
12 "syscall"
13 "time"
14
@@ -42,7 +40,7 @@ func main() {
40 defaultRelayURLs = "https://localhost:4017"
41 }
42
45 - flag.StringVar(&flagRelayURLs, "relay", defaultRelayURLs, "Portal relay server API URLs (comma-separated, https only) [env: RELAYS]")
43 + flag.StringVar(&flagRelayURLs, "relays", defaultRelayURLs, "Portal relay server API URLs (comma-separated, https only) [env: RELAYS]")
44 flag.StringVar(&flagHost, "host", os.Getenv("APP_HOST"), "Target host to proxy to (host:port or URL) [env: APP_HOST]")
45 flag.StringVar(&flagName, "name", os.Getenv("APP_NAME"), "Service name [env: APP_NAME]")
46 flag.StringVar(&flagDesc, "description", os.Getenv("APP_DESCRIPTION"), "Service description metadata [env: APP_DESCRIPTION]")
@@ -64,93 +62,63 @@ func runTunnel() error {
62 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
63 defer stop()
64
67 - relayURLs := parseURLs(flagRelayURLs)
65 + relayURLs, err := normalizeRelayURLs(flagRelayURLs)
66 + if err != nil {
67 + return err
68 + }
69 if len(relayURLs) == 0 {
70 return errors.New("no relay URLs provided")
71 }
71 - relayURL := strings.TrimSpace(relayURLs[0])
72
73 logger.Info().
74 Str("local", flagHost).
75 + Int("relay_count", len(relayURLs)).
76 Strs("relays", relayURLs).
77 Msg("starting portal tunnel")
77 - if len(relayURLs) > 1 {
78 - logger.Warn().Str("selected_relay", relayURL).Msg("multiple relays configured; using first relay only")
79 - }
78
81 - sdkClient, err := sdk.NewClient(sdk.ClientConfig{RelayURL: relayURL})
82 - if err != nil {
83 - return fmt.Errorf("service %s: failed to create client: %w", flagName, err)
84 - }
85 - defer sdkClient.Close()
86 -
87 - listener, err := sdkClient.Listen(ctx, sdk.ListenRequest{
79 + listenReq := sdk.ListenRequest{
80 Name: flagName,
81 Metadata: types.LeaseMetadata{
82 Description: flagDesc,
91 - Tags: parseURLs(flagTags),
83 + Tags: splitCSV(flagTags),
84 Owner: flagOwner,
85 Thumbnail: flagThumbnail,
86 Hide: flagHide,
87 },
96 - })
97 - if err != nil {
98 - return fmt.Errorf("service %s: failed to register service: %w", flagName, err)
88 }
100 - defer listener.Close()
89
102 - go func() {
103 - <-ctx.Done()
104 - _ = listener.Close()
105 - }()
106 -
107 - logger.Info().
108 - Str("relay", relayURL).
109 - Str("lease_id", listener.LeaseID()).
110 - Strs("public_urls", listener.PublicURLs()).
111 - Msg("tunnel ready")
90 + runtimes, err := startRelayRuntimes(ctx, relayURLs, listenReq)
91 + if err != nil {
92 + return fmt.Errorf("service %s: failed to start relays: %w", flagName, err)
93 + }
94
113 - connCount := 0
95 var connWG sync.WaitGroup
96 + var connCount atomic.Int64
97 + relayDone := make(chan relayLoopResult, len(runtimes))
98
116 -loop:
117 - for {
118 - select {
119 - case <-ctx.Done():
120 - logger.Info().Msg("tunnel shutting down")
121 - break loop
122 - default:
123 - }
124 -
125 - relayConn, err := listener.Accept()
126 - if err != nil {
127 - if errors.Is(err, net.ErrClosed) {
128 - logger.Info().Msg("tunnel listener closed")
129 - break loop
130 - }
131 - select {
132 - case <-ctx.Done():
133 - break loop
134 - default:
135 - logger.Error().Err(err).Msg("failed to accept relay connection")
136 - continue
137 - }
138 - }
139 -
140 - connCount++
99 + for _, runtime := range runtimes {
100 logger.Info().
142 - Int("conn_id", connCount).
143 - Str("remote_addr", relayConn.RemoteAddr().String()).
144 - Msg("accepted relay connection")
101 + Str("relay", runtime.relayURL).
102 + Str("lease_id", runtime.listener.LeaseID()).
103 + Strs("public_urls", runtime.listener.PublicURLs()).
104 + Msg("relay tunnel ready")
105 + go runtime.run(ctx, flagHost, &connWG, &connCount, relayDone)
106 + }
107
146 - connWG.Add(1)
147 - go func(connID int, relayConn net.Conn) {
148 - defer connWG.Done()
149 - if err := proxyConnection(ctx, flagHost, relayConn); err != nil {
150 - logger.Error().Err(err).Int("conn_id", connID).Msg("proxy connection failed")
151 - }
152 - logger.Info().Int("conn_id", connID).Msg("proxy connection closed")
153 - }(connCount, relayConn)
108 + waitErr := waitForRelayLoops(ctx, relayDone, len(runtimes))
109 + if waitErr != nil {
110 + stop()
111 + }
112 + closeErr := closeRelayRuntimes(runtimes)
113 + if waitErr != nil {
114 + logger.Error().Err(waitErr).Msg("relay supervisor exited with error")
115 + }
116 + if closeErr != nil {
117 + logger.Error().Err(closeErr).Msg("relay shutdown failed")
118 + }
119 +
120 + if ctx.Err() != nil {
121 + logger.Info().Msg("tunnel shutting down")
122 }
123
124 done := make(chan struct{})
@@ -166,128 +134,5 @@ loop:
134 }
135
136 logger.Info().Msg("tunnel shutdown complete")
169 - return nil
170 -}
171 -
172 -var bufferPool = sync.Pool{
173 - New: func() any {
174 - b := make([]byte, 64*1024)
175 - return &b
176 - },
177 -}
178 -
179 -func proxyConnection(ctx context.Context, localAddr string, relayConn net.Conn) error {
180 - defer relayConn.Close()
181 -
182 - targetAddr, err := normalizeTargetAddr(localAddr)
183 - if err != nil {
184 - return fmt.Errorf("invalid --host value %q: %w", localAddr, err)
185 - }
186 -
187 - dialer := &net.Dialer{Timeout: 5 * time.Second}
188 - localConn, err := dialer.DialContext(ctx, "tcp", targetAddr)
189 - if err != nil {
190 - return writeEmptyHTTPResponse(relayConn)
191 - }
192 - defer localConn.Close()
193 -
194 - errCh := make(chan error, 2)
195 - stopCh := make(chan struct{})
196 -
197 - go func() {
198 - select {
199 - case <-ctx.Done():
200 - _ = relayConn.Close()
201 - _ = localConn.Close()
202 - case <-stopCh:
203 - }
204 - }()
205 -
206 - go func() {
207 - bufPtr := bufferPool.Get().(*[]byte)
208 - defer bufferPool.Put(bufPtr)
209 - _, err := io.CopyBuffer(localConn, relayConn, *bufPtr)
210 - if tcpConn, ok := localConn.(*net.TCPConn); ok {
211 - _ = tcpConn.CloseWrite()
212 - }
213 - errCh <- err
214 - }()
215 -
216 - go func() {
217 - bufPtr := bufferPool.Get().(*[]byte)
218 - defer bufferPool.Put(bufPtr)
219 - _, err := io.CopyBuffer(relayConn, localConn, *bufPtr)
220 - _ = relayConn.Close()
221 - errCh <- err
222 - }()
223 -
224 - var firstErr error
225 - for range 2 {
226 - if err := <-errCh; err != nil && firstErr == nil {
227 - firstErr = err
228 - }
229 - }
230 -
231 - close(stopCh)
232 - if errors.Is(firstErr, io.EOF) || errors.Is(firstErr, net.ErrClosed) {
233 - return nil
234 - }
235 - return firstErr
236 -}
237 -
238 -func writeEmptyHTTPResponse(conn net.Conn) error {
239 - htmlBody := `<!DOCTYPE html>
240 -<html>
241 -<head><title>Service Unavailable</title></head>
242 -<body style="font-family:sans-serif;text-align:center;padding:50px;">
243 -<h1>Service Unavailable</h1>
244 -<p>The local service is not currently running.</p>
245 -<p>Please start your local application and refresh this page.</p>
246 -</body>
247 -</html>`
248 - response := fmt.Sprintf("HTTP/1.1 503 Service Unavailable\r\n"+
249 - "Content-Type: text/html; charset=utf-8\r\n"+
250 - "Content-Length: %d\r\n"+
251 - "Connection: close\r\n"+
252 - "\r\n%s", len(htmlBody), htmlBody)
253 - _, err := conn.Write([]byte(response))
254 - return err
255 -}
256 -
257 -func parseURLs(raw string) []string {
258 - if strings.TrimSpace(raw) == "" {
259 - return nil
260 - }
261 - parts := strings.Split(raw, ",")
262 - out := make([]string, 0, len(parts))
263 - for _, part := range parts {
264 - part = strings.TrimSpace(part)
265 - if part != "" {
266 - out = append(out, part)
267 - }
268 - }
269 - return out
270 -}
271 -
272 -func normalizeTargetAddr(raw string) (string, error) {
273 - raw = strings.TrimSpace(raw)
274 - if raw == "" {
275 - return "", errors.New("target address is required")
276 - }
277 - if strings.Contains(raw, "://") {
278 - if strings.HasPrefix(strings.ToLower(raw), "http://") {
279 - raw = strings.TrimPrefix(raw, "http://")
280 - }
281 - if strings.HasPrefix(strings.ToLower(raw), "https://") {
282 - raw = strings.TrimPrefix(raw, "https://")
283 - }
284 - raw = strings.TrimSuffix(raw, "/")
285 - }
286 - if _, _, err := net.SplitHostPort(raw); err == nil {
287 - return raw, nil
288 - }
289 - if strings.Count(raw, ":") == 0 {
290 - return net.JoinHostPort(raw, "80"), nil
291 - }
292 - return "", fmt.Errorf("invalid target address %q", raw)
137 + return errors.Join(waitErr, closeErr)
138 }
cmd/portal-tunnel/relays.go new
+308
@@ -0,0 +1,308 @@
1 +package main
2 +
3 +import (
4 + "context"
5 + "errors"
6 + "fmt"
7 + "io"
8 + "net"
9 + "net/url"
10 + "strings"
11 + "sync"
12 + "sync/atomic"
13 + "time"
14 +
15 + "github.com/rs/zerolog/log"
16 +
17 + "gosuda.org/portal/sdk"
18 +)
19 +
20 +type relayRuntime struct {
21 + relayURL string
22 + client *sdk.Client
23 + listener *sdk.Listener
24 +}
25 +
26 +type relayLoopResult struct {
27 + leaseID string
28 + relayURL string
29 + err error
30 +}
31 +
32 +func (r *relayRuntime) run(ctx context.Context, localAddr string, connWG *sync.WaitGroup, connCount *atomic.Int64, done chan<- relayLoopResult) {
33 + logger := log.With().
34 + Str("component", "portal-tunnel").
35 + Str("relay", r.relayURL).
36 + Str("lease_id", r.listener.LeaseID()).
37 + Logger()
38 +
39 + var runErr error
40 + defer func() {
41 + done <- relayLoopResult{
42 + leaseID: r.listener.LeaseID(),
43 + relayURL: r.relayURL,
44 + err: runErr,
45 + }
46 + }()
47 +
48 + for {
49 + relayConn, err := r.listener.Accept()
50 + if err != nil {
51 + if errors.Is(err, net.ErrClosed) || errors.Is(err, context.Canceled) || ctx.Err() != nil {
52 + return
53 + }
54 + runErr = err
55 + return
56 + }
57 +
58 + connID := connCount.Add(1)
59 + logger.Info().
60 + Int64("conn_id", connID).
61 + Str("remote_addr", relayConn.RemoteAddr().String()).
62 + Msg("accepted relay connection")
63 +
64 + connWG.Add(1)
65 + go func(connID int64, relayConn net.Conn) {
66 + defer connWG.Done()
67 + if err := proxyConnection(ctx, localAddr, relayConn); err != nil {
68 + logger.Error().Err(err).Int64("conn_id", connID).Msg("proxy connection failed")
69 + }
70 + logger.Info().Int64("conn_id", connID).Msg("proxy connection closed")
71 + }(connID, relayConn)
72 + }
73 +}
74 +
75 +func startRelayRuntimes(ctx context.Context, relayURLs []string, req sdk.ListenRequest) ([]*relayRuntime, error) {
76 + runtimes := make([]*relayRuntime, 0, len(relayURLs))
77 + for _, relayURL := range relayURLs {
78 + client, err := sdk.NewClient(sdk.ClientConfig{RelayURL: relayURL})
79 + if err != nil {
80 + _ = closeRelayRuntimes(runtimes)
81 + return nil, fmt.Errorf("create relay client %s: %w", relayURL, err)
82 + }
83 +
84 + listener, err := client.Listen(ctx, req)
85 + if err != nil {
86 + client.Close()
87 + _ = closeRelayRuntimes(runtimes)
88 + return nil, fmt.Errorf("register relay lease %s: %w", relayURL, err)
89 + }
90 +
91 + runtimes = append(runtimes, &relayRuntime{
92 + relayURL: relayURL,
93 + client: client,
94 + listener: listener,
95 + })
96 + }
97 + return runtimes, nil
98 +}
99 +
100 +func closeRelayRuntimes(runtimes []*relayRuntime) error {
101 + var closeErr error
102 + for _, runtime := range runtimes {
103 + if runtime == nil {
104 + continue
105 + }
106 + if runtime.listener != nil {
107 + closeErr = errors.Join(closeErr, runtime.listener.Close())
108 + }
109 + if runtime.client != nil {
110 + runtime.client.Close()
111 + }
112 + }
113 + return closeErr
114 +}
115 +
116 +func waitForRelayLoops(ctx context.Context, done <-chan relayLoopResult, relayCount int) error {
117 + logger := log.With().Str("component", "portal-tunnel").Logger()
118 + active := relayCount
119 +
120 + for active > 0 {
121 + result := <-done
122 + active--
123 +
124 + switch {
125 + case result.err != nil:
126 + logger.Error().
127 + Err(result.err).
128 + Str("relay", result.relayURL).
129 + Str("lease_id", result.leaseID).
130 + Int("remaining_relays", active).
131 + Msg("relay accept loop stopped")
132 + case ctx.Err() != nil:
133 + logger.Info().
134 + Str("relay", result.relayURL).
135 + Str("lease_id", result.leaseID).
136 + Int("remaining_relays", active).
137 + Msg("relay accept loop stopped during shutdown")
138 + default:
139 + logger.Warn().
140 + Str("relay", result.relayURL).
141 + Str("lease_id", result.leaseID).
142 + Int("remaining_relays", active).
143 + Msg("relay accept loop stopped")
144 + }
145 + }
146 +
147 + if ctx.Err() != nil {
148 + return nil
149 + }
150 + return errors.New("all relay listeners stopped")
151 +}
152 +
153 +func normalizeRelayURLs(raw string) ([]string, error) {
154 + seen := make(map[string]struct{})
155 + var relayURLs []string
156 + for _, relayURL := range splitCSV(raw) {
157 + normalized, err := normalizeRelayURL(relayURL)
158 + if err != nil {
159 + return nil, err
160 + }
161 + if _, ok := seen[normalized]; ok {
162 + continue
163 + }
164 + seen[normalized] = struct{}{}
165 + relayURLs = append(relayURLs, normalized)
166 + }
167 + return relayURLs, nil
168 +}
169 +
170 +func normalizeRelayURL(raw string) (string, error) {
171 + u, err := url.Parse(strings.TrimSpace(raw))
172 + if err != nil {
173 + return "", fmt.Errorf("parse relay url: %w", err)
174 + }
175 + if !strings.EqualFold(u.Scheme, "https") {
176 + return "", fmt.Errorf("relay url must use https: %q", raw)
177 + }
178 + if u.Host == "" {
179 + return "", fmt.Errorf("relay url host is empty: %q", raw)
180 + }
181 + u.Path = strings.TrimRight(u.Path, "/")
182 + u.RawQuery = ""
183 + u.Fragment = ""
184 + return u.String(), nil
185 +}
186 +
187 +var bufferPool = sync.Pool{
188 + New: func() any {
189 + b := make([]byte, 64*1024)
190 + return &b
191 + },
192 +}
193 +
194 +func proxyConnection(ctx context.Context, localAddr string, relayConn net.Conn) error {
195 + defer relayConn.Close()
196 +
197 + targetAddr, err := normalizeTargetAddr(localAddr)
198 + if err != nil {
199 + return fmt.Errorf("invalid --host value %q: %w", localAddr, err)
200 + }
201 +
202 + dialer := &net.Dialer{Timeout: 5 * time.Second}
203 + localConn, err := dialer.DialContext(ctx, "tcp", targetAddr)
204 + if err != nil {
205 + return writeEmptyHTTPResponse(relayConn)
206 + }
207 + defer localConn.Close()
208 +
209 + errCh := make(chan error, 2)
210 + stopCh := make(chan struct{})
211 +
212 + go func() {
213 + select {
214 + case <-ctx.Done():
215 + _ = relayConn.Close()
216 + _ = localConn.Close()
217 + case <-stopCh:
218 + }
219 + }()
220 +
221 + go func() {
222 + bufPtr := bufferPool.Get().(*[]byte)
223 + defer bufferPool.Put(bufPtr)
224 + _, err := io.CopyBuffer(localConn, relayConn, *bufPtr)
225 + if tcpConn, ok := localConn.(*net.TCPConn); ok {
226 + _ = tcpConn.CloseWrite()
227 + }
228 + errCh <- err
229 + }()
230 +
231 + go func() {
232 + bufPtr := bufferPool.Get().(*[]byte)
233 + defer bufferPool.Put(bufPtr)
234 + _, err := io.CopyBuffer(relayConn, localConn, *bufPtr)
235 + _ = relayConn.Close()
236 + errCh <- err
237 + }()
238 +
239 + var firstErr error
240 + for range 2 {
241 + if err := <-errCh; err != nil && firstErr == nil {
242 + firstErr = err
243 + }
244 + }
245 +
246 + close(stopCh)
247 + if errors.Is(firstErr, io.EOF) || errors.Is(firstErr, net.ErrClosed) {
248 + return nil
249 + }
250 + return firstErr
251 +}
252 +
253 +func writeEmptyHTTPResponse(conn net.Conn) error {
254 + htmlBody := `<!DOCTYPE html>
255 +<html>
256 +<head><title>Service Unavailable</title></head>
257 +<body style="font-family:sans-serif;text-align:center;padding:50px;">
258 +<h1>Service Unavailable</h1>
259 +<p>The local service is not currently running.</p>
260 +<p>Please start your local application and refresh this page.</p>
261 +</body>
262 +</html>`
263 + response := fmt.Sprintf("HTTP/1.1 503 Service Unavailable\r\n"+
264 + "Content-Type: text/html; charset=utf-8\r\n"+
265 + "Content-Length: %d\r\n"+
266 + "Connection: close\r\n"+
267 + "\r\n%s", len(htmlBody), htmlBody)
268 + _, err := conn.Write([]byte(response))
269 + return err
270 +}
271 +
272 +func splitCSV(raw string) []string {
273 + if strings.TrimSpace(raw) == "" {
274 + return nil
275 + }
276 + parts := strings.Split(raw, ",")
277 + out := make([]string, 0, len(parts))
278 + for _, part := range parts {
279 + part = strings.TrimSpace(part)
280 + if part != "" {
281 + out = append(out, part)
282 + }
283 + }
284 + return out
285 +}
286 +
287 +func normalizeTargetAddr(raw string) (string, error) {
288 + raw = strings.TrimSpace(raw)
289 + if raw == "" {
290 + return "", errors.New("target address is required")
291 + }
292 + if strings.Contains(raw, "://") {
293 + if strings.HasPrefix(strings.ToLower(raw), "http://") {
294 + raw = strings.TrimPrefix(raw, "http://")
295 + }
296 + if strings.HasPrefix(strings.ToLower(raw), "https://") {
297 + raw = strings.TrimPrefix(raw, "https://")
298 + }
299 + raw = strings.TrimSuffix(raw, "/")
300 + }
301 + if _, _, err := net.SplitHostPort(raw); err == nil {
302 + return raw, nil
303 + }
304 + if strings.Count(raw, ":") == 0 {
305 + return net.JoinHostPort(raw, "80"), nil
306 + }
307 + return "", fmt.Errorf("invalid target address %q", raw)
308 +}
cmd/relay-server/tunnel.go
+3 -3
@@ -75,7 +75,7 @@ fi
75
76 chmod +x "$BIN_PATH"
77
78 -set -- "$BIN_PATH" --relay "$RELAYS" --host "${APP_HOST:-localhost:3000}"
78 +set -- "$BIN_PATH" --relays "$RELAYS" --host "${APP_HOST:-localhost:3000}"
79 [ -n "${APP_NAME:-}" ] && set -- "$@" --name "$APP_NAME"
80 [ -n "${APP_DESCRIPTION:-}" ] && set -- "$@" --description "$APP_DESCRIPTION"
81 [ -n "${APP_TAGS:-}" ] && set -- "$@" --tags "$APP_TAGS"
@@ -92,7 +92,7 @@ exec "$@"
92 const tunnelPowerShellScriptTemplate = `$ErrorActionPreference = "Stop"
93
94 $BaseUrl = if ($env:BASE_URL) { $env:BASE_URL } else { "%s" }
95 -$RelayUrl = if ($env:RELAY_URL) { $env:RELAY_URL } else { $BaseUrl }
95 +$RelayUrls = if ($env:RELAYS) { $env:RELAYS } else { $BaseUrl }
96 $OriginalSecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol
97 [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
98
@@ -149,7 +149,7 @@ if ($ActualHash -ne $ExpectedHash) {
149 exit 1
150 }
151
152 -$ArgsList = @("--relay", $RelayUrl)
152 +$ArgsList = @("--relays", $RelayUrls)
153
154 if ($env:HOST) { $ArgsList += "--host", $env:HOST } else { $ArgsList += "--host", "localhost:3000" }
155 if ($env:NAME) { $ArgsList += "--name", $env:NAME }