add http routes exclude loopback in discovery
add http routes exclude loopback in discovery
rabbitprincess committed
Mar 25, 2026 at 23:17 UTC
2d7fe3ef02ec7d24d92a16bcc41bcb80e641d9f8
10 files changed
+497
-26
cmd/portal-tunnel/README.md
+14
-1
@@ -28,12 +28,23 @@ portal expose localhost:8080 \
28
--owner "Portal Operator"
29
```
30
31
+Multi-port HTTP aggregation example:
32
+
33
+```text
34
+portal expose --name myapp \
35
+ --http-route /api=http://127.0.0.1:3001 \
36
+ --http-route /=http://127.0.0.1:5173
37
+```
38
+
39
## Commands
40
41
### `portal expose [flags] <target>`
42
43
- `<target>` accepts a bare port like `3000`, a `host:port`, or an `http(s)://host:port` URL.
44
- Bare ports resolve to `127.0.0.1:<port>`.
45
+- Instead of `<target>`, you can repeat `--http-route PATH=UPSTREAM` to aggregate multiple local HTTP services behind one public URL.
46
+- Route matching is longest-prefix-first. `/api=http://127.0.0.1:3001` matches `/api/*` and strips the `/api` prefix before proxying to the upstream.
47
+- 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.
48
- `--name` is optional. When omitted, the CLI generates a name for that run.
49
- `--relays` sets the relay API URLs for that run.
50
- `--discovery=false` disables the public registry seed list and the discovery expansion loop for that run.
@@ -51,6 +62,7 @@ Flags:
62
--thumbnail Service thumbnail URL metadata
63
--owner Service owner metadata
64
--hide Hide service from discovery
65
+--http-route HTTP route mapping in PATH=UPSTREAM form; repeat for multiple routes
66
```
67
68
### `portal list [flags]`
@@ -62,7 +74,7 @@ Legacy execution compatibility has been removed:
74
75
- Use `portal expose ...` explicitly; bare `portal [flags]` is no longer accepted.
76
- Runtime `APP_*`, `RELAYS`, and `DEFAULT_RELAYS` environment variable fallbacks are no longer used.
65
-- Pass the local target as the required positional `<target>` argument.
77
+- Pass either the local target as the positional `<target>` argument or repeat `--http-route` for routed HTTP mode.
78
79
## Install Behavior
80
@@ -84,3 +96,4 @@ Legacy execution compatibility has been removed:
96
- Tenant TLS is provisioned automatically through the relay keyless signer. The SDK fetches the relay certificate chain and uses `/v1/sign` for remote signing.
97
- TLS self-probe mismatches log warnings by default. Use `--ban-mitm` to reject relays that terminate tenant TLS.
98
- When the local service is unreachable, the tunnel returns an HTTP 503 page.
99
+- `--http-route` mode is HTTP-only and cannot be combined with `--udp`.
cmd/portal-tunnel/http_routes.go
new
+306
@@ -0,0 +1,306 @@
1
+package main
2
+
3
+import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "net"
8
+ "net/http"
9
+ "net/http/httputil"
10
+ "net/url"
11
+ "sort"
12
+ "strings"
13
+
14
+ "github.com/rs/zerolog/log"
15
+
16
+ "github.com/gosuda/portal/v2/utils"
17
+)
18
+
19
+type httpRoute struct {
20
+ prefix string
21
+ upstream *url.URL
22
+ proxy *httputil.ReverseProxy
23
+}
24
+
25
+type httpRouteMeta struct {
26
+ publicHost string
27
+ publicScheme string
28
+}
29
+
30
+type httpRouteMetaKey struct{}
31
+
32
+func newHTTPRouteHandler(rawRoutes []string) (http.Handler, error) {
33
+ if len(rawRoutes) == 0 {
34
+ return nil, errors.New("at least one --http-route is required")
35
+ }
36
+
37
+ routes := make([]*httpRoute, 0, len(rawRoutes))
38
+ seen := make(map[string]struct{}, len(rawRoutes))
39
+ for _, rawRoute := range rawRoutes {
40
+ route, err := parseHTTPRoute(rawRoute)
41
+ if err != nil {
42
+ return nil, err
43
+ }
44
+ if _, ok := seen[route.prefix]; ok {
45
+ return nil, fmt.Errorf("duplicate --http-route prefix %q", route.prefix)
46
+ }
47
+ seen[route.prefix] = struct{}{}
48
+ route.proxy = route.newReverseProxy()
49
+ routes = append(routes, route)
50
+ }
51
+
52
+ sort.Slice(routes, func(i, j int) bool {
53
+ if len(routes[i].prefix) == len(routes[j].prefix) {
54
+ return routes[i].prefix < routes[j].prefix
55
+ }
56
+ return len(routes[i].prefix) > len(routes[j].prefix)
57
+ })
58
+
59
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
60
+ requestPath := r.URL.Path
61
+ if requestPath == "" {
62
+ requestPath = "/"
63
+ }
64
+ for _, route := range routes {
65
+ if route.prefix == "/" || requestPath == route.prefix || strings.HasPrefix(requestPath, route.prefix+"/") {
66
+ route.proxy.ServeHTTP(w, r)
67
+ return
68
+ }
69
+ }
70
+ http.NotFound(w, r)
71
+ }), nil
72
+}
73
+
74
+func parseHTTPRoute(raw string) (*httpRoute, error) {
75
+ raw = strings.TrimSpace(raw)
76
+ if raw == "" {
77
+ return nil, errors.New("invalid --http-route: expected PATH=UPSTREAM")
78
+ }
79
+
80
+ prefixRaw, upstreamRaw, ok := strings.Cut(raw, "=")
81
+ if !ok {
82
+ return nil, fmt.Errorf("invalid --http-route %q: expected PATH=UPSTREAM", raw)
83
+ }
84
+
85
+ prefix := strings.TrimSpace(prefixRaw)
86
+ switch {
87
+ case prefix == "":
88
+ return nil, fmt.Errorf("invalid --http-route prefix %q: %w", strings.TrimSpace(prefixRaw), errors.New("prefix is required"))
89
+ case !strings.HasPrefix(prefix, "/"):
90
+ return nil, fmt.Errorf("invalid --http-route prefix %q: %w", strings.TrimSpace(prefixRaw), errors.New("prefix must start with /"))
91
+ }
92
+ prefix = utils.NormalizeURLPath(prefix)
93
+
94
+ upstreamInput := strings.TrimSpace(upstreamRaw)
95
+ if upstreamInput == "" {
96
+ return nil, fmt.Errorf("invalid --http-route upstream %q: %w", strings.TrimSpace(upstreamRaw), errors.New("upstream is required"))
97
+ }
98
+
99
+ if !strings.Contains(upstreamInput, "://") {
100
+ target, err := utils.NormalizeLoopbackTarget(upstreamInput)
101
+ if err != nil {
102
+ return nil, fmt.Errorf("invalid --http-route upstream %q: %w", strings.TrimSpace(upstreamRaw), err)
103
+ }
104
+ upstreamInput = "http://" + target
105
+ }
106
+
107
+ upstream, err := url.Parse(upstreamInput)
108
+ if err != nil {
109
+ return nil, fmt.Errorf("invalid --http-route upstream %q: %w", strings.TrimSpace(upstreamRaw), err)
110
+ }
111
+ if upstream.Host == "" {
112
+ return nil, fmt.Errorf("invalid --http-route upstream %q: %w", strings.TrimSpace(upstreamRaw), errors.New("upstream host is required"))
113
+ }
114
+ if upstream.Scheme != "http" && upstream.Scheme != "https" {
115
+ return nil, fmt.Errorf("invalid --http-route upstream %q: %w", strings.TrimSpace(upstreamRaw), errors.New("upstream scheme must be http or https"))
116
+ }
117
+ upstream.Fragment = ""
118
+ upstream.Path = utils.NormalizeURLPath(upstream.Path)
119
+
120
+ return &httpRoute{
121
+ prefix: prefix,
122
+ upstream: upstream,
123
+ }, nil
124
+}
125
+
126
+func (r *httpRoute) newReverseProxy() *httputil.ReverseProxy {
127
+ return &httputil.ReverseProxy{
128
+ Rewrite: r.rewriteRequest,
129
+ ModifyResponse: r.modifyResponse,
130
+ ErrorHandler: func(w http.ResponseWriter, req *http.Request, err error) {
131
+ log.Error().
132
+ Err(err).
133
+ Str("route_prefix", r.prefix).
134
+ Str("upstream", r.upstream.String()).
135
+ Msg("http route proxy failed")
136
+ http.Error(w, "bad gateway", http.StatusBadGateway)
137
+ },
138
+ }
139
+}
140
+
141
+func (r *httpRoute) rewriteRequest(pr *httputil.ProxyRequest) {
142
+ outboundPath, outboundRawPath := r.stripPrefix(pr.In.URL.Path, pr.In.URL.RawPath)
143
+ pr.Out.URL.Path = outboundPath
144
+ pr.Out.URL.RawPath = outboundRawPath
145
+ pr.Out.URL.RawQuery = pr.In.URL.RawQuery
146
+ pr.SetURL(r.upstream)
147
+ pr.SetXForwarded()
148
+ if r.prefix != "/" {
149
+ pr.Out.Header.Set("X-Forwarded-Prefix", r.prefix)
150
+ }
151
+
152
+ publicScheme := "http"
153
+ if pr.In.TLS != nil {
154
+ publicScheme = "https"
155
+ } else {
156
+ proto := strings.TrimSpace(pr.In.Header.Get("X-Forwarded-Proto"))
157
+ if first, _, ok := strings.Cut(proto, ","); ok {
158
+ proto = first
159
+ }
160
+ if proto = strings.ToLower(strings.TrimSpace(proto)); proto != "" {
161
+ publicScheme = proto
162
+ }
163
+ }
164
+
165
+ meta := httpRouteMeta{
166
+ publicHost: pr.In.Host,
167
+ publicScheme: publicScheme,
168
+ }
169
+ pr.Out = pr.Out.WithContext(context.WithValue(pr.Out.Context(), httpRouteMetaKey{}, meta))
170
+}
171
+
172
+func (r *httpRoute) modifyResponse(resp *http.Response) error {
173
+ if resp == nil || resp.Request == nil {
174
+ return nil
175
+ }
176
+
177
+ meta, _ := resp.Request.Context().Value(httpRouteMetaKey{}).(httpRouteMeta)
178
+ r.rewriteLocation(resp.Header, meta)
179
+ r.rewriteSetCookies(resp.Header, meta.publicHost)
180
+ return nil
181
+}
182
+
183
+func (r *httpRoute) stripPrefix(requestPath, rawPath string) (string, string) {
184
+ requestPath = utils.NormalizeURLPath(requestPath)
185
+ if r.prefix == "/" {
186
+ return requestPath, rawPath
187
+ }
188
+ if requestPath == r.prefix {
189
+ return "/", ""
190
+ }
191
+
192
+ trimmedPath := strings.TrimPrefix(requestPath, r.prefix)
193
+ if trimmedPath == "" {
194
+ trimmedPath = "/"
195
+ }
196
+
197
+ trimmedRawPath := rawPath
198
+ if trimmedRawPath != "" {
199
+ if trimmedRawPath == r.prefix {
200
+ trimmedRawPath = "/"
201
+ } else if strings.HasPrefix(trimmedRawPath, r.prefix+"/") {
202
+ trimmedRawPath = strings.TrimPrefix(trimmedRawPath, r.prefix)
203
+ }
204
+ }
205
+ return trimmedPath, trimmedRawPath
206
+}
207
+
208
+func (r *httpRoute) rewriteLocation(header http.Header, meta httpRouteMeta) {
209
+ location := strings.TrimSpace(header.Get("Location"))
210
+ if location == "" {
211
+ return
212
+ }
213
+
214
+ parsed, err := url.Parse(location)
215
+ if err != nil {
216
+ return
217
+ }
218
+
219
+ switch {
220
+ case parsed.IsAbs():
221
+ if !strings.EqualFold(parsed.Scheme, r.upstream.Scheme) || !strings.EqualFold(parsed.Host, r.upstream.Host) {
222
+ return
223
+ }
224
+ parsed.Scheme = meta.publicScheme
225
+ parsed.Host = meta.publicHost
226
+ parsed.Path = r.mapUpstreamPathToPublic(parsed.Path)
227
+ parsed.RawPath = ""
228
+ header.Set("Location", parsed.String())
229
+ case strings.HasPrefix(location, "/"):
230
+ parsed.Path = r.mapUpstreamPathToPublic(parsed.Path)
231
+ parsed.RawPath = ""
232
+ header.Set("Location", parsed.String())
233
+ }
234
+}
235
+
236
+func (r *httpRoute) rewriteSetCookies(header http.Header, publicHost string) {
237
+ values := header.Values("Set-Cookie")
238
+ if len(values) == 0 {
239
+ return
240
+ }
241
+
242
+ publicDomain := strings.ToLower(strings.TrimSpace(publicHost))
243
+ if host, port, err := net.SplitHostPort(publicDomain); err == nil && port != "" {
244
+ publicDomain = host
245
+ }
246
+ publicDomain = strings.Trim(publicDomain, "[]")
247
+ upstreamDomain := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(r.upstream.Hostname()), "."))
248
+ header.Del("Set-Cookie")
249
+ for _, value := range values {
250
+ cookie, err := http.ParseSetCookie(value)
251
+ if err != nil {
252
+ header.Add("Set-Cookie", value)
253
+ continue
254
+ }
255
+
256
+ changed := false
257
+ if strings.TrimSpace(cookie.Path) != "" {
258
+ rewrittenPath := r.mapUpstreamPathToPublic(cookie.Path)
259
+ if rewrittenPath != cookie.Path {
260
+ cookie.Path = rewrittenPath
261
+ changed = true
262
+ }
263
+ }
264
+
265
+ currentDomain := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(cookie.Domain), "."))
266
+ if currentDomain != "" && currentDomain != publicDomain &&
267
+ (currentDomain == upstreamDomain || utils.IsLocalRelayHost(currentDomain)) {
268
+ cookie.Domain = ""
269
+ changed = true
270
+ }
271
+
272
+ if changed {
273
+ header.Add("Set-Cookie", cookie.String())
274
+ continue
275
+ }
276
+ header.Add("Set-Cookie", value)
277
+ }
278
+}
279
+
280
+func (r *httpRoute) mapUpstreamPathToPublic(raw string) string {
281
+ raw = utils.NormalizeURLPath(raw)
282
+ if r.prefix != "/" && (raw == r.prefix || strings.HasPrefix(raw, r.prefix+"/")) {
283
+ return raw
284
+ }
285
+
286
+ base := utils.NormalizeURLPath(r.upstream.Path)
287
+ publicRest := raw
288
+ switch {
289
+ case base == "/":
290
+ case raw == base:
291
+ publicRest = "/"
292
+ case strings.HasPrefix(raw, base+"/"):
293
+ publicRest = strings.TrimPrefix(raw, base)
294
+ }
295
+
296
+ if r.prefix == "/" {
297
+ return publicRest
298
+ }
299
+ if publicRest == "/" {
300
+ return r.prefix
301
+ }
302
+ if strings.HasPrefix(publicRest, "/") {
303
+ return r.prefix + publicRest
304
+ }
305
+ return r.prefix + "/" + publicRest
306
+}
cmd/portal-tunnel/main.go
+29
-3
@@ -43,6 +43,7 @@ type exposeFlags struct {
43
thumbnail string
44
hide bool
45
targetAddr string
46
+ httpRoutes []string
47
udp bool
48
udpAddr string
49
}
@@ -61,6 +62,7 @@ func runExposeCommand(args []string) error {
62
utils.StringFlag(fs, &flags.owner, "owner", "", "Service owner metadata")
63
utils.StringFlag(fs, &flags.thumbnail, "thumbnail", "", "Service thumbnail URL metadata")
64
utils.BoolFlag(fs, &flags.hide, "hide", false, "Hide service from discovery")
65
+ utils.RepeatedStringFlag(fs, &flags.httpRoutes, "http-route", "HTTP route mapping in PATH=UPSTREAM form; repeat to aggregate multiple local HTTP services behind one public URL")
66
utils.BoolFlagEnv(fs, &flags.udp, "udp", false, "Enable public UDP relay in addition to the default TCP relay", "UDP_ENABLED")
67
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")
68
@@ -77,12 +79,23 @@ func runExposeCommand(args []string) error {
79
printExposeUsage(os.Stderr)
80
return err
81
}
80
- if flags.targetAddr == "" {
82
+ switch {
83
+ case flags.targetAddr == "" && len(flags.httpRoutes) == 0:
84
printExposeUsage(os.Stderr)
82
- return errors.New("target is required")
85
+ return errors.New("target or at least one --http-route is required")
86
+ case flags.targetAddr != "" && len(flags.httpRoutes) > 0:
87
+ printExposeUsage(os.Stderr)
88
+ return errors.New("target cannot be combined with --http-route")
89
+ case len(flags.httpRoutes) > 0 && flags.udp:
90
+ printExposeUsage(os.Stderr)
91
+ return errors.New("--udp cannot be combined with --http-route")
92
}
93
if flags.name == "" {
85
- flags.name, err = defaultExposeName(flags.targetAddr, utils.RandomID("cli_"))
94
+ defaultTarget := flags.targetAddr
95
+ if defaultTarget == "" && len(flags.httpRoutes) > 0 {
96
+ defaultTarget = strings.Join(flags.httpRoutes, ",")
97
+ }
98
+ flags.name, err = defaultExposeName(defaultTarget, utils.RandomID("cli_"))
99
if err != nil {
100
return fmt.Errorf("derive service name: %w", err)
101
}
@@ -111,6 +124,15 @@ func runExposeCommand(args []string) error {
124
if err != nil {
125
return fmt.Errorf("service %s: failed to start relays: %w", flags.name, err)
126
}
127
+ if len(flags.httpRoutes) > 0 {
128
+ handler, err := newHTTPRouteHandler(flags.httpRoutes)
129
+ if err != nil {
130
+ _ = exposure.Close()
131
+ return err
132
+ }
133
+ defer exposure.Close()
134
+ return exposure.RunHTTP(ctx, handler, "")
135
+ }
136
return proxyExposure(ctx, exposure, flags.name)
137
}
138
@@ -235,11 +257,13 @@ func printRootUsage(w io.Writer) {
257
utils.WriteCommandUsage(w,
258
[]string{
259
"portal expose [flags] <target>",
260
+ "portal expose [flags] --http-route PATH=UPSTREAM [--http-route PATH=UPSTREAM]",
261
"portal list [flags]",
262
},
263
[]string{
264
"portal expose 3000",
265
"portal expose localhost:8080 --name my-app",
266
+ "portal expose --http-route /api=http://127.0.0.1:3001 --http-route /=http://127.0.0.1:5173 --name my-app",
267
"portal expose 3000 --udp --udp-addr 127.0.0.1:5353",
268
"portal list",
269
},
@@ -250,10 +274,12 @@ func printExposeUsage(w io.Writer) {
274
utils.WriteCommandUsage(w,
275
[]string{
276
"portal expose [flags] <target>",
277
+ "portal expose [flags] --http-route PATH=UPSTREAM [--http-route PATH=UPSTREAM]",
278
},
279
[]string{
280
"portal expose 3000",
281
"portal expose localhost:8080 --name my-app",
282
+ "portal expose --http-route /api=http://127.0.0.1:3001 --http-route /=http://127.0.0.1:5173 --name my-app",
283
"portal expose 3000 --udp --udp-addr 127.0.0.1:5353",
284
"portal expose 3000 --ban-mitm",
285
"portal expose 3000 --relays https://portal.example.com --discovery=false",
portal/acme/acme.go
+6
-16
@@ -26,6 +26,8 @@ import (
26
lego "github.com/go-acme/lego/v4/lego"
27
"github.com/go-acme/lego/v4/registration"
28
"github.com/rs/zerolog/log"
29
+
30
+ "github.com/gosuda/portal/v2/utils"
31
)
32
33
const (
@@ -94,7 +96,7 @@ func NewManager(cfg Config) (*Manager, error) {
96
if cfg.BaseDomain == "" {
97
return nil, errors.New("acme base domain is required")
98
}
97
- if isLocalhost(cfg.BaseDomain) {
99
+ if utils.IsLocalRelayHost(cfg.BaseDomain) {
100
return &Manager{
101
cfg: cfg,
102
stopCh: make(chan struct{}),
@@ -126,7 +128,7 @@ func (m *Manager) EnsureCertificate(ctx context.Context) (string, string, error)
128
return "", "", errors.New("acme manager is nil")
129
}
130
129
- if isLocalhost(m.cfg.BaseDomain) {
131
+ if utils.IsLocalRelayHost(m.cfg.BaseDomain) {
132
if err := ensureLocalDevelopmentCertificate(m.cfg.KeyDir, m.cfg.BaseDomain); err != nil {
133
return "", "", err
134
}
@@ -169,7 +171,7 @@ func (m *Manager) EnsureTLSMaterial(ctx context.Context) ([]byte, []byte, error)
171
}
172
173
func (m *Manager) Start(ctx context.Context) {
172
- if m == nil || isLocalhost(m.cfg.BaseDomain) {
174
+ if m == nil || utils.IsLocalRelayHost(m.cfg.BaseDomain) {
175
return
176
}
177
@@ -282,7 +284,7 @@ func (m *Manager) maintenanceLoop(ctx context.Context) {
284
}
285
286
func (m *Manager) syncDNS(ctx context.Context) error {
285
- if m == nil || isLocalhost(m.cfg.BaseDomain) {
287
+ if m == nil || utils.IsLocalRelayHost(m.cfg.BaseDomain) {
288
return nil
289
}
290
if m.dns == nil {
@@ -542,18 +544,6 @@ func normalizeHost(host string) string {
544
return host
545
}
546
545
-func isLocalhost(host string) bool {
546
- host = normalizeHost(host)
547
- switch host {
548
- case "", "localhost":
549
- return true
550
- }
551
- if ip := net.ParseIP(host); ip != nil {
552
- return ip.IsLoopback()
553
- }
554
- return strings.HasSuffix(host, ".localhost")
555
-}
556
-
547
func detectPublicIPv4(ctx context.Context) (string, error) {
548
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
549
defer cancel()
portal/discovery/discovery.go
+9
-4
@@ -20,7 +20,7 @@ type Resolver func(context.Context, types.DiscoverRequest) (types.DiscoverRespon
20
const defaultRequestTimeout = 15 * time.Second
21
22
func DiscoverBootstraps(ctx context.Context, peers []string, req types.DiscoverRequest, rootCAPEM []byte) ([]string, error) {
23
- peers, err := utils.NormalizeRelayURLs(peers...)
23
+ peers, err := utils.ExcludeLocalRelayURLs(peers...)
24
if err != nil {
25
return nil, err
26
}
@@ -44,7 +44,12 @@ func DiscoverBootstraps(ctx context.Context, peers []string, req types.DiscoverR
44
continue
45
}
46
47
- bootstraps, err = utils.MergeRelayURLs(bootstraps, nil, resp.Bootstraps)
47
+ discoveredBootstraps, err := utils.ExcludeLocalRelayURLs(resp.Bootstraps...)
48
+ if err != nil {
49
+ discoverErr = errors.Join(discoverErr, fmt.Errorf("filter %q bootstraps: %w", peer, err))
50
+ continue
51
+ }
52
+ bootstraps, err = utils.MergeRelayURLs(bootstraps, nil, discoveredBootstraps)
53
if err != nil {
54
discoverErr = errors.Join(discoverErr, fmt.Errorf("merge %q bootstraps: %w", peer, err))
55
continue
@@ -190,7 +195,7 @@ func buildResponseBootstraps(selfURLs, bootstraps, extra []string) ([]string, er
195
return nil, err
196
}
197
if len(selfURLs) == 0 {
193
- return merged, nil
198
+ return utils.ExcludeLocalRelayURLs(merged...)
199
}
200
201
normalizedSelf, err := utils.NormalizeRelayURLs(selfURLs...)
@@ -201,5 +206,5 @@ func buildResponseBootstraps(selfURLs, bootstraps, extra []string) ([]string, er
206
if err != nil {
207
return nil, fmt.Errorf("normalize bootstraps: %w", err)
208
}
204
- return resolvedBootstraps, nil
209
+ return utils.ExcludeLocalRelayURLs(resolvedBootstraps...)
210
}
portal/server.go
+8
@@ -150,6 +150,10 @@ func NewServer(cfg ServerConfig) (*Server, error) {
150
if err != nil {
151
return nil, err
152
}
153
+ bootstraps, err = utils.ExcludeLocalRelayURLs(bootstraps...)
154
+ if err != nil {
155
+ return nil, err
156
+ }
157
s.discoveryBootstraps = bootstraps
158
}
159
@@ -596,6 +600,10 @@ func (s *Server) mergeDiscoveryBootstraps(inputs []string) ([]string, error) {
600
if err != nil {
601
return nil, err
602
}
603
+ next, err = utils.ExcludeLocalRelayURLs(next...)
604
+ if err != nil {
605
+ return nil, err
606
+ }
607
608
existing := make(map[string]struct{}, len(s.discoveryBootstraps))
609
for _, bootstrap := range s.discoveryBootstraps {
portal/server_test.go
+32
-2
@@ -5,6 +5,7 @@ import (
5
"crypto/tls"
6
"encoding/json"
7
"net/http"
8
+ "reflect"
9
"strings"
10
"testing"
11
@@ -256,14 +257,43 @@ func TestServerStartServesOptionalDiscoveryRoutes(t *testing.T) {
257
if envelope.Data.Hostname != "demo.localhost" {
258
t.Fatalf("resolve hostname = %q, want %q", envelope.Data.Hostname, "demo.localhost")
259
}
259
- if len(envelope.Data.Bootstraps) != 3 || envelope.Data.Bootstraps[0] != "https://localhost:4017" || envelope.Data.Bootstraps[1] != "https://bootstrap.example.com" || envelope.Data.Bootstraps[2] != "https://relay-a.example.com" {
260
- t.Fatalf("resolve bootstraps = %v, want [%q %q %q]", envelope.Data.Bootstraps, "https://localhost:4017", "https://bootstrap.example.com", "https://relay-a.example.com")
260
+ if !reflect.DeepEqual(envelope.Data.Bootstraps, []string{"https://bootstrap.example.com", "https://relay-a.example.com"}) {
261
+ t.Fatalf("resolve bootstraps = %v, want [%q %q]", envelope.Data.Bootstraps, "https://bootstrap.example.com", "https://relay-a.example.com")
262
}
263
if !server.DiscoveryEnabled() {
264
t.Fatal("DiscoveryEnabled() = false, want true")
265
}
266
}
267
268
+func TestServerMergeDiscoveryBootstrapsSkipsLocalRelayHosts(t *testing.T) {
269
+ t.Parallel()
270
+
271
+ server, err := NewServer(ServerConfig{
272
+ PortalURL: "https://portal.example.com",
273
+ Bootstraps: []string{"https://bootstrap.example.com"},
274
+ DiscoveryEnabled: true,
275
+ })
276
+ if err != nil {
277
+ t.Fatalf("NewServer() error = %v", err)
278
+ }
279
+
280
+ added, err := server.mergeDiscoveryBootstraps([]string{
281
+ "https://localhost:4017",
282
+ "https://relay-a.example.com",
283
+ "https://127.0.0.1:4017",
284
+ })
285
+ if err != nil {
286
+ t.Fatalf("mergeDiscoveryBootstraps() error = %v", err)
287
+ }
288
+
289
+ if !reflect.DeepEqual(added, []string{"https://relay-a.example.com"}) {
290
+ t.Fatalf("mergeDiscoveryBootstraps() added = %v, want [%q]", added, "https://relay-a.example.com")
291
+ }
292
+ if !reflect.DeepEqual(server.discoveryBootstrapsSnapshot(), []string{"https://bootstrap.example.com", "https://relay-a.example.com"}) {
293
+ t.Fatalf("discoveryBootstrapsSnapshot() = %v, want [%q %q]", server.discoveryBootstrapsSnapshot(), "https://bootstrap.example.com", "https://relay-a.example.com")
294
+ }
295
+}
296
+
297
func TestServerStartHidesDiscoveryRoutesWhenDisabled(t *testing.T) {
298
t.Parallel()
299
utils/cmd.go
+10
@@ -138,6 +138,16 @@ func IntFlagEnv(fs *flag.FlagSet, target *int, name string, fallback int, parse
138
ensureFlagSet(fs).IntVar(target, name, ResolveIntEnv(fallback, parse, envNames...), flagUsage(usage, envNames...))
139
}
140
141
+func RepeatedStringFlag(fs *flag.FlagSet, target *[]string, name, usage string) {
142
+ ensureFlagSet(fs).Func(name, usage, func(value string) error {
143
+ if target == nil {
144
+ return nil
145
+ }
146
+ *target = append(*target, value)
147
+ return nil
148
+ })
149
+}
150
+
151
func ensureFlagSet(fs *flag.FlagSet) *flag.FlagSet {
152
if fs != nil {
153
return fs
utils/utils.go
+42
@@ -12,6 +12,7 @@ import (
12
"io"
13
"net"
14
"net/url"
15
+ "path"
16
"strings"
17
"time"
18
"unicode"
@@ -175,6 +176,21 @@ func NormalizeHostname(host string) string {
176
return host
177
}
178
179
+// NormalizeURLPath canonicalizes URL paths to a rooted, slash-trimmed form.
180
+func NormalizeURLPath(raw string) string {
181
+ clean := path.Clean(strings.TrimSpace(raw))
182
+ if clean == "." || clean == "" {
183
+ return "/"
184
+ }
185
+ if !strings.HasPrefix(clean, "/") {
186
+ clean = "/" + clean
187
+ }
188
+ if clean != "/" {
189
+ clean = strings.TrimSuffix(clean, "/")
190
+ }
191
+ return clean
192
+}
193
+
194
func NormalizeRelayURLs(inputs ...string) ([]string, error) {
195
out := make([]string, 0, len(inputs))
196
@@ -280,6 +296,32 @@ func MergeRelayURLs(current, excluded, inputs []string) ([]string, error) {
296
return FilterRelayURLs(merged, excluded), nil
297
}
298
299
+func ExcludeLocalRelayURLs(inputs ...string) ([]string, error) {
300
+ normalized, err := NormalizeRelayURLs(inputs...)
301
+ if err != nil {
302
+ return nil, err
303
+ }
304
+ if len(normalized) == 0 {
305
+ return nil, nil
306
+ }
307
+
308
+ filtered := normalized[:0]
309
+ for _, input := range normalized {
310
+ parsed, err := url.Parse(input)
311
+ if err != nil {
312
+ return nil, fmt.Errorf("parse relay url %q: %w", input, err)
313
+ }
314
+ if IsLocalRelayHost(parsed.Hostname()) {
315
+ continue
316
+ }
317
+ filtered = append(filtered, input)
318
+ }
319
+ if len(filtered) == 0 {
320
+ return nil, nil
321
+ }
322
+ return filtered, nil
323
+}
324
+
325
func uniqueURLs(inputs []string) []string {
326
if len(inputs) == 0 {
327
return nil
utils/utils_test.go
+41
@@ -28,6 +28,28 @@ func TestNormalizeRelayURLs(t *testing.T) {
28
}
29
}
30
31
+func TestNormalizeURLPath(t *testing.T) {
32
+ t.Parallel()
33
+
34
+ cases := []struct {
35
+ input string
36
+ want string
37
+ }{
38
+ {input: "", want: "/"},
39
+ {input: " ", want: "/"},
40
+ {input: "api", want: "/api"},
41
+ {input: "/api/", want: "/api"},
42
+ {input: "/api/../v1//", want: "/v1"},
43
+ {input: "/", want: "/"},
44
+ }
45
+
46
+ for _, tc := range cases {
47
+ if got := NormalizeURLPath(tc.input); got != tc.want {
48
+ t.Fatalf("NormalizeURLPath(%q) = %q, want %q", tc.input, got, tc.want)
49
+ }
50
+ }
51
+}
52
+
53
func TestFilterRelayURLs(t *testing.T) {
54
t.Parallel()
55
@@ -74,6 +96,25 @@ func TestAppendUniqueRelayURL(t *testing.T) {
96
}
97
}
98
99
+func TestExcludeLocalRelayURLs(t *testing.T) {
100
+ t.Parallel()
101
+
102
+ got, err := ExcludeLocalRelayURLs(
103
+ "https://localhost:4017",
104
+ "https://127.0.0.1:4017",
105
+ "https://relay.example.com/base",
106
+ "https://demo.localhost",
107
+ )
108
+ if err != nil {
109
+ t.Fatalf("ExcludeLocalRelayURLs() error = %v", err)
110
+ }
111
+
112
+ want := []string{"https://relay.example.com/base"}
113
+ if !reflect.DeepEqual(got, want) {
114
+ t.Fatalf("ExcludeLocalRelayURLs() = %v, want %v", got, want)
115
+ }
116
+}
117
+
118
func TestParseCIDRs(t *testing.T) {
119
t.Parallel()
120