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