feat: update relay API URL handling and remove deprecated configurations

gosunuts committed Feb 24, 2026 at 18:00 UTC 126bc11d1dbd65e155072eb4e8079596da952cc4
22 files changed +230 -238
.github/workflows/ci.yml
+1 -19
@@ -25,19 +25,6 @@ jobs:
25 - name: Download dependencies
26 run: go mod download
27
28 - - name: Install protobuf compiler
29 - run: |
30 - sudo apt-get update
31 - sudo apt-get install -y protobuf-compiler
32 -
33 - - name: Install protoc-gen-go and protoc-gen-go-vtproto
34 - run: |
35 - go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
36 - go install github.com/planetscale/vtprotobuf/cmd/protoc-gen-go-vtproto@latest
37 -
38 - - name: Generate protobuf files
39 - run: make build-protoc
40 -
28 - name: Run vet
29 run: go vet ./...
30
@@ -108,12 +95,7 @@ jobs:
95 - name: Install build dependencies
96 run: |
97 sudo apt-get update
111 - sudo apt-get install -y protobuf-compiler binaryen
112 -
113 - - name: Install protoc-gen-go and protoc-gen-go-vtproto
114 - run: |
115 - go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
116 - go install github.com/planetscale/vtprotobuf/cmd/protoc-gen-go-vtproto@latest
98 + sudo apt-get install -y binaryen
99
100 - name: Build all components
101 run: make build
.github/workflows/release.yml
+1 -6
@@ -30,12 +30,7 @@ jobs:
30 - name: Install build dependencies
31 run: |
32 sudo apt-get update
33 - sudo apt-get install -y protobuf-compiler binaryen
34 -
35 - - name: Install protoc-gen-go and protoc-gen-go-vtproto
36 - run: |
37 - go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
38 - go install github.com/planetscale/vtprotobuf/cmd/protoc-gen-go-vtproto@latest
33 + sudo apt-get install -y binaryen
34
35 - name: Run GoReleaser
36 uses: goreleaser/goreleaser-action@v6
AGENTS.md
+1 -3
@@ -10,7 +10,6 @@ Build:
10 - `make build-frontend` (React admin UI)
11 - `make build-wasm` (webclient WASM)
12 - `make build-tunnel` (portal-tunnel binaries)
13 -- `make build-protoc` (protobufs)
13
14 Run:
15 - `make run` (run `./bin/relay-server`)
@@ -39,7 +38,7 @@ Portal is a relay network that connects Apps (service publishers) and Clients (s
38 Core components:
39 - Relay server: `cmd/relay-server` (HTTP + WS relay, admin UI serving)
40 - Relay core logic: `portal/` (lease manager, connection handlers, forwarding)
42 -- Crypto + protocols: `portal/core/` and `portal/core/proto/` (RDSEC/RDVERB)
41 +- Crypto + protocols: `portal/core/`
42 - SDK for Apps: `sdk/`
43 - Tunnel client: `cmd/portal-tunnel/` (exposes local services)
44 - Webclient: `cmd/webclient/` (WASM + service worker served by relay)
@@ -64,7 +63,6 @@ Core components:
63
64 - `cmd/relay-server/` (entrypoint and HTTP/WS relay)
65 - `portal/` (core relay logic)
67 -- `portal/core/proto/` (protocol definitions)
66 - `sdk/` (App integration)
67 - `cmd/portal-tunnel/` (tunnel client)
68 - `cmd/webclient/` (WASM client)
Dockerfile
+1 -2
@@ -39,8 +39,7 @@ FROM gcr.io/distroless/static-debian12:nonroot
39 COPY --from=go-builder /src/bin/relay-server /usr/bin/relay-server
40
41 ENV PORTAL_URL=http://localhost:4017
42 -ENV PORTAL_APP_URL=http://*.localhost:4017
43 -ENV BOOTSTRAP_URIS=ws://localhost:4017/relay
42 +ENV BOOTSTRAP_URIS=http://localhost:4017
43 ENV ADMIN_SECRET_KEY=
44 ENV NOINDEX=false
45 ENV TZ=UTC
Makefile
+2 -12
@@ -1,11 +1,10 @@
1 -.PHONY: help fmt vet lint test vuln tidy all run build build-protoc build-frontend build-tunnel build-server clean
1 +.PHONY: help fmt vet lint test vuln tidy all run build build-frontend build-tunnel build-server clean
2
3 .DEFAULT_GOAL := help
4
5 help:
6 @echo "Available targets:"
7 - @echo " make build - Build everything (protoc, frontend, server)"
8 - @echo " make build-protoc - Generate Go code from protobuf definitions"
7 + @echo " make build - Build everything (frontend, tunnel, server)"
8 @echo " make build-frontend - Build React frontend (Tailwind CSS 4)"
9 @echo " make build-tunnel - Build portal-tunnel binaries"
10 @echo " make build-server - Build Go relay server (includes frontend build)"
@@ -40,15 +39,6 @@ run:
39 # Convenience target
40 build: build-frontend build-tunnel build-server
41
43 -build-protoc:
44 - protoc -I . \
45 - --go_out=. \
46 - --go_opt=paths=source_relative \
47 - --go-vtproto_out=. \
48 - --go-vtproto_opt=paths=source_relative \
49 - portal/core/proto/rdsec/rdsec.proto \
50 - portal/core/proto/rdverb/rdverb.proto
51 -
42 # Build React frontend with Tailwind CSS 4
43 build-frontend:
44 @echo "[frontend] building React frontend..."
buf.gen.yaml deleted
-12
@@ -1,12 +0,0 @@
1 -version: v2
2 -managed:
3 - enabled: true
4 -plugins:
5 - - local: protoc-gen-go
6 - out: .
7 - opt: paths=source_relative
8 - - local: protoc-gen-go-vtproto
9 - out: .
10 - opt: paths=source_relative
11 -inputs:
12 - - directory: proto
buf.yaml deleted
-8
@@ -1,8 +0,0 @@
1 -# For details on buf.yaml configuration, visit https://buf.build/docs/configuration/v2/buf-yaml
2 -version: v2
3 -lint:
4 - use:
5 - - STANDARD
6 -breaking:
7 - use:
8 - - FILE
cmd/demo-app/main.go
+1 -1
@@ -36,7 +36,7 @@ var (
36 )
37
38 func main() {
39 - flag.StringVar(&flagServerURL, "server-url", "ws://localhost:4017/relay", "relay URL (ws/wss/http/https)")
39 + flag.StringVar(&flagServerURL, "server-url", "http://localhost:4017", "relay API URL (http/https)")
40 flag.IntVar(&flagPort, "port", 8092, "local demo HTTP port")
41 flag.StringVar(&flagName, "name", "demo-app", "backend display name")
42 flag.StringVar(&flagDesc, "description", "Portal demo connectivity app", "lease description")
cmd/portal-tunnel/README.md
+2 -2
@@ -10,7 +10,7 @@ You can run the tunnel using command-line flags or a configuration file.
10
11 ```bash
12 ./bin/portal-tunnel --host localhost:8080 \
13 - --relay ws://portal.gosuda.org/relay,ws://portal.thumbgo.kr/relay,ws://portal.iwanhae.kr/relay \
13 + --relay https://portal.gosuda.org,https://portal.thumbgo.kr,https://portal.iwanhae.kr \
14 --name <service> \
15 --description "Service description" \
16 --tags tag1,tag2 \
@@ -24,7 +24,7 @@ Usage:
24 portal-tunnel [OPTIONS] [ARGUMENTS]
25
26 Options:
27 - --relay Portal relay server URLs (comma-separated) [default: ws://localhost:4017/relay] [env: RELAYS]
27 + --relay Portal relay server API URLs (comma-separated, http/https) [default: http://localhost:4017] [env: RELAYS]
28 --host Target host to proxy to (host:port or URL) [env: APP_HOST]
29 --name Service name [env: APP_NAME]
30 --protocols ALPN protocols (comma-separated) [default: http/1.1,h2] [env: APP_PROTOCOLS]
cmd/portal-tunnel/main.go
+1 -15
@@ -31,7 +31,7 @@ var bufferPool = sync.Pool{
31 type Config struct {
32 _ struct{} `version:"0.0.1" command:"portal-tunnel" about:"Expose local services through Portal relay"`
33
34 - RelayURLs string `flag:"relay" env:"RELAYS" default:"ws://localhost:4017/relay" about:"Portal relay server URLs (comma-separated)"`
34 + RelayURLs string `flag:"relay" env:"RELAYS" default:"http://localhost:4017" about:"Portal relay server API URLs (comma-separated, http/https)"`
35 Host string `flag:"host" env:"APP_HOST" about:"Target host to proxy to (host:port or URL)"`
36 Name string `flag:"name" env:"APP_NAME" about:"Service name"`
37
@@ -261,20 +261,6 @@ func proxyConnection(ctx context.Context, localAddr string, relayConn net.Conn)
261 }
262
263 if err != nil {
264 - // Return HTTP 503 response to relay instead of closing connection
265 - log.Error().
266 - Str("local_addr", localAddr).
267 - Err(err).
268 - Msg("Failed to connect to local service after retries")
269 -
270 - // Send HTTP 503 response
271 - httpResponse := "HTTP/1.1 503 Service Unavailable\r\n" +
272 - "Content-Type: text/plain\r\n" +
273 - "Content-Length: 29\r\n" +
274 - "Connection: close\r\n" +
275 - "\r\n" +
276 - "Local service not available"
277 - relayConn.Write([]byte(httpResponse))
264 return fmt.Errorf("local service unavailable: %w", err)
265 }
266
cmd/relay-server/admin.go
+13 -10
@@ -604,10 +604,7 @@ func (a *Admin) convertLeaseEntriesToAdminRows(serv *portal.RelayServer) []lease
604 }
605 }
606
607 - since := now.Sub(leaseEntry.LastSeen)
608 - if since < 0 {
609 - since = 0
610 - }
607 + since := max(now.Sub(leaseEntry.LastSeen), 0)
608 lastSeenStr := func(d time.Duration) string {
609 if d >= time.Hour {
610 h := int(d / time.Hour)
@@ -648,11 +645,7 @@ func (a *Admin) convertLeaseEntriesToAdminRows(serv *portal.RelayServer) []lease
645 dnsLabel = dnsLabel[:8] + "..."
646 }
647
651 - base := flagPortalAppURL
652 - if base == "" {
653 - base = flagPortalURL
654 - }
655 - link := fmt.Sprintf("//%s.%s/", lease.Name, utils.StripWildCard(utils.StripScheme(base)))
648 + link := fmt.Sprintf("//%s.%s/", lease.Name, utils.PortalHostPort(flagPortalURL))
649
650 var bps int64
651 if a.bpsManager != nil {
@@ -669,6 +662,16 @@ func (a *Admin) convertLeaseEntriesToAdminRows(serv *portal.RelayServer) []lease
662 }
663 }
664
665 + metadata := lease.Metadata
666 + metadataStr := ""
667 + if metadata.Description != "" || len(metadata.Tags) > 0 || metadata.Thumbnail != "" || metadata.Owner != "" || metadata.Hide {
668 + if b, err := json.Marshal(metadata); err == nil {
669 + metadataStr = string(b)
670 + } else {
671 + log.Warn().Err(err).Str("lease_id", identityID).Msg("[Admin] Failed to marshal lease metadata")
672 + }
673 + }
674 +
675 rows = append(rows, leaseRow{
676 Peer: identityID,
677 Name: name,
@@ -682,7 +685,7 @@ func (a *Admin) convertLeaseEntriesToAdminRows(serv *portal.RelayServer) []lease
685 Link: link,
686 StaleRed: !connected && since >= 15*time.Second,
687 Hide: leaseEntry.ParsedMetadata != nil && leaseEntry.ParsedMetadata.Hide,
685 - Metadata: "", // TODO: Convert portal.Metadata to string if needed
688 + Metadata: metadataStr,
689 BPS: bps,
690 IsApproved: a.approveManager.GetApprovalMode() == manager.ApprovalModeAuto || a.approveManager.IsLeaseApproved(identityID),
691 IsDenied: a.approveManager.IsLeaseDenied(identityID),
cmd/relay-server/frontend.go
+1 -5
@@ -249,11 +249,7 @@ func convertLeaseEntriesToRows(serv *portal.RelayServer, admin *Admin) []leaseRo
249 dnsLabel = dnsLabel[:8] + "..."
250 }
251
252 - base := flagPortalAppURL
253 - if base == "" {
254 - base = flagPortalURL
255 - }
256 - link := fmt.Sprintf("//%s.%s/", lease.Name, utils.StripWildCard(utils.StripScheme(base)))
252 + link := fmt.Sprintf("//%s.%s/", lease.Name, utils.PortalHostPort(flagPortalURL))
253
254 var bps int64
255 if bpsMgr := admin.GetBPSManager(); bpsMgr != nil {
cmd/relay-server/main.go
-7
@@ -24,7 +24,6 @@ import (
24
25 var (
26 flagPortalURL string
27 - flagPortalAppURL string
27 flagBootstraps []string
28 flagALPN string
29 flagPort int
@@ -42,10 +41,6 @@ func main() {
41 // Prefer explicit scheme for localhost so downstream URL building is unambiguous
42 defaultPortalURL = "http://localhost:4017"
43 }
45 - defaultAppURL := os.Getenv("PORTAL_APP_URL")
46 - if defaultAppURL == "" {
47 - defaultAppURL = utils.DefaultAppPattern(defaultPortalURL)
48 - }
44 defaultBootstraps := os.Getenv("BOOTSTRAP_URIS")
45 if defaultBootstraps == "" {
46 defaultBootstraps = utils.DefaultBootstrapFrom(defaultPortalURL)
@@ -53,7 +48,6 @@ func main() {
48
49 var flagBootstrapsCSV string
50 flag.StringVar(&flagPortalURL, "portal-url", defaultPortalURL, "base URL for portal frontend (env: PORTAL_URL)")
56 - flag.StringVar(&flagPortalAppURL, "portal-app-url", defaultAppURL, "subdomain wildcard URL (env: PORTAL_APP_URL)")
51 flag.StringVar(&flagBootstrapsCSV, "bootstraps", defaultBootstraps, "bootstrap addresses (comma-separated)")
52 flag.StringVar(&flagALPN, "alpn", "http/1.1", "ALPN identifier for this service")
53 flag.IntVar(&flagPort, "port", 4017, "app UI and HTTP proxy port")
@@ -79,7 +73,6 @@ func runServer() error {
73
74 log.Info().
75 Str("portal_base_url", flagPortalURL).
82 - Str("app_url", flagPortalAppURL).
76 Str("bootstrap_uris", strings.Join(flagBootstraps, ",")).
77 Msg("[server] frontend configuration")
78
cmd/relay-server/registry.go
+3 -17
@@ -25,14 +25,11 @@ type SDKRegistry struct {
25 }
26
27 // NewSDKRegistry creates a new SDK registry
28 -func NewSDKRegistry(server *portal.RelayServer, sniRouter *sni.Router, appURL string) *SDKRegistry {
29 - baseHost := strings.ToLower(strings.TrimSpace(
30 - utils.StripPort(utils.StripWildCard(utils.StripScheme(appURL))),
31 - ))
28 +func NewSDKRegistry(server *portal.RelayServer, sniRouter *sni.Router) *SDKRegistry {
29 return &SDKRegistry{
30 server: server,
31 sniRouter: sniRouter,
35 - baseHost: baseHost,
32 + baseHost: utils.PortalBaseHostNoPort(flagPortalURL),
33 }
34 }
35
@@ -152,10 +149,7 @@ func (r *SDKRegistry) HandleRegister(w http.ResponseWriter, req *http.Request) {
149 Msg("[Registry] Lease registered")
150
151 // Build public URL
155 - publicURL := ""
156 - if flagPortalAppURL != "" {
157 - publicURL = "https://" + registerReq.Name + "." + stripWildcard(flagPortalAppURL)
158 - }
152 + publicURL := utils.ServicePublicURL(flagPortalURL, registerReq.Name)
153
154 writeJSON(w, RegisterResponse{
155 Success: true,
@@ -334,11 +328,3 @@ func (r *SDKRegistry) unregisterSNIRoute(leaseID string) {
328 }
329 r.sniRouter.UnregisterRouteByLeaseID(leaseID)
330 }
337 -
338 -// stripWildcard removes the wildcard prefix from a domain
339 -func stripWildcard(domain string) string {
340 - if len(domain) > 2 && domain[:2] == "*." {
341 - return domain[2:]
342 - }
343 - return domain
344 -}
cmd/relay-server/serve.go
+5 -4
@@ -58,7 +58,7 @@ func serveHTTP(addr string, serv *portal.RelayServer, sniRouter *sni.Router, adm
58 })
59
60 // SDK Registry API for lease registration (used by SDK and tunnel clients)
61 - registry := NewSDKRegistry(serv, sniRouter, flagPortalAppURL)
61 + registry := NewSDKRegistry(serv, sniRouter)
62 appMux.HandleFunc("/api/register", registry.HandleRegister)
63 appMux.HandleFunc("/api/unregister", registry.HandleUnregister)
64 appMux.HandleFunc("/api/renew", registry.HandleRenew)
@@ -85,9 +85,10 @@ func serveHTTP(addr string, serv *portal.RelayServer, sniRouter *sni.Router, adm
85 })
86
87 // Create the main handler
88 + appDomain := utils.DefaultAppPattern(flagPortalURL)
89 handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
90 // Handle subdomain requests
90 - if utils.IsSubdomain(flagPortalAppURL, r.Host) {
91 + if utils.IsSubdomain(appDomain, r.Host) {
92 log.Debug().
93 Str("host", r.Host).
94 Str("url", r.URL.String()).
@@ -171,7 +172,7 @@ func redirectToHTTPS(w http.ResponseWriter, r *http.Request) {
172 // based on the lease's TLSEnabled setting.
173 // Returns true if TLS is NOT enabled (can proxy via HTTP).
174 func shouldProxyHTTP(host string, serv *portal.RelayServer) bool {
174 - leaseName, ok := leaseNameFromHost(host, flagPortalAppURL)
175 + leaseName, ok := leaseNameFromHost(host, utils.DefaultAppPattern(flagPortalURL))
176 if !ok {
177 log.Debug().Str("host", host).Msg("[proxy] shouldProxyHTTP: failed to extract lease name")
178 return false
@@ -194,7 +195,7 @@ func shouldProxyHTTP(host string, serv *portal.RelayServer) bool {
195 }
196
197 func proxyToHTTP(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer) {
197 - leaseName, ok := leaseNameFromHost(r.Host, flagPortalAppURL)
198 + leaseName, ok := leaseNameFromHost(r.Host, utils.DefaultAppPattern(flagPortalURL))
199 if !ok {
200 http.Error(w, "invalid subdomain", http.StatusBadRequest)
201 return
docker-compose.yml
+1 -2
@@ -10,8 +10,7 @@ services:
10 - "${PORTAL_PORT:-4017}"
11 environment:
12 PORTAL_URL: ${PORTAL_URL:-http://localhost:${PORTAL_PORT:-4017}}
13 - PORTAL_APP_URL: ${PORTAL_APP_URL:-http://*.localhost:${PORTAL_PORT:-4017}}
14 - BOOTSTRAP_URIS: ${BOOTSTRAP_URIS:-ws://localhost:${PORTAL_PORT:-4017}/relay}
13 + BOOTSTRAP_URIS: ${BOOTSTRAP_URIS:-http://localhost:${PORTAL_PORT:-4017}}
14 ADMIN_SECRET_KEY: ${ADMIN_SECRET_KEY:-}
15 ports:
16 - "4017:4017"
docs/portal-deploy-guide.md
+1 -2
@@ -26,8 +26,7 @@ Portal requires a single wildcard TLS certificate for all app subdomains (`*.you
26 ```
27 PORTAL_PORT=4017
28 PORTAL_URL=https://yourdomain.com
29 - PORTAL_APP_URL=https://*.yourdomain.com
30 - BOOTSTRAP_URIS=wss://yourdomain.com/relay
29 + BOOTSTRAP_URIS=https://yourdomain.com
30 ```
31
32 ## Deploy
justfile deleted
-20
@@ -1,20 +0,0 @@
1 -fmt:
2 - golangci-lint run --fast-only --allow-parallel-runners --fix > /dev/null || true
3 - gofmt -w .
4 -
5 -lint:
6 - golangci-lint run -D errcheck --allow-parallel-runners
7 -
8 -lint-fix:
9 - golangci-lint run --allow-parallel-runners --fix
10 -
11 -tidy:
12 - go mod tidy
13 -
14 -vet:
15 - go vet ./...
16 -
17 -test:
18 - go test -race -v ./...
19 -
20 -all: fmt vet test tidy lint-fix
sdk/listener.go
+6 -9
@@ -469,21 +469,18 @@ func normalizeRelayAPIURL(raw string) (string, error) {
469 }
470
471 switch u.Scheme {
472 - case "ws":
473 - u.Scheme = "http"
474 - case "wss":
475 - u.Scheme = "https"
472 case "http", "https":
473 default:
478 - return "", fmt.Errorf("unsupported relay URL scheme: %q", u.Scheme)
474 + return "", fmt.Errorf("unsupported relay URL scheme: %q (use http/https)", u.Scheme)
475 + }
476 +
477 + if p := strings.TrimSpace(u.Path); p != "" && p != "/" {
478 + return "", fmt.Errorf("relay URL must not include path: %q", raw)
479 }
480
481 u.RawQuery = ""
482 u.Fragment = ""
483 - u.Path = strings.TrimSuffix(u.Path, "/")
484 - if u.Path == "/relay" {
485 - u.Path = ""
486 - }
483 + u.Path = ""
484
485 return strings.TrimSuffix(u.String(), "/"), nil
486 }
sdk/listener_test.go
+4 -4
@@ -14,13 +14,13 @@ func TestNormalizeRelayAPIURL(t *testing.T) {
14 want string
15 wantErr bool
16 }{
17 - {name: "ws relay path", in: "ws://localhost:4017/relay", want: "http://localhost:4017"},
18 - {name: "wss relay path", in: "wss://example.com/relay", want: "https://example.com"},
17 {name: "localhost subdomain to localhost", in: "http://demo-app.localhost:4017", want: "http://localhost:4017"},
20 - {name: "localhost subdomain with relay path", in: "ws://demo-app.localhost:4017/relay", want: "http://localhost:4017"},
18 {name: "http base", in: "http://example.com", want: "http://example.com"},
19 {name: "https base", in: "https://example.com/", want: "https://example.com"},
20 {name: "bare host", in: "localhost:4017", want: "http://localhost:4017"},
21 + {name: "invalid ws scheme", in: "ws://localhost:4017", wantErr: true},
22 + {name: "invalid wss scheme", in: "wss://example.com", wantErr: true},
23 + {name: "invalid relay path", in: "http://localhost:4017/relay", wantErr: true},
24 {name: "invalid scheme", in: "ftp://example.com", wantErr: true},
25 {name: "empty", in: "", wantErr: true},
26 }
@@ -50,7 +50,7 @@ func TestNormalizeRelayAPIURL(t *testing.T) {
50 func TestFirstRelayAPIURL(t *testing.T) {
51 t.Parallel()
52
53 - got, err := firstRelayAPIURL([]string{"invalid://relay", "ws://localhost:4017/relay"})
53 + got, err := firstRelayAPIURL([]string{"invalid://relay", "http://localhost:4017"})
54 if err != nil {
55 t.Fatalf("unexpected error: %v", err)
56 }
utils/url.go
+93 -42
@@ -21,55 +21,45 @@ func IsURLSafeName(name string) bool {
21 }
22
23 // NormalizePortalURL takes various user-friendly server inputs and
24 -// converts them into a proper WebSocket URL.
24 +// converts them into a relay API base URL.
25 // Examples:
26 -// - "wss://localhost:4017/relay" -> unchanged
27 -// - "ws://localhost:4017/relay" -> unchanged
28 -// - "http://example.com" -> "ws://example.com/relay"
29 -// - "https://example.com" -> "wss://example.com/relay"
30 -// - "localhost:4017" -> "wss://localhost:4017/relay"
31 -// - "example.com" -> "wss://example.com/relay"
26 +// - "http://example.com" -> "http://example.com"
27 +// - "https://example.com" -> "https://example.com"
28 +// - "localhost:4017" -> "http://localhost:4017"
29 +// - "example.com" -> "http://example.com"
30 func NormalizePortalURL(raw string) (string, error) {
31 server := strings.TrimSpace(raw)
32 if server == "" {
33 return "", fmt.Errorf("bootstrap server is empty")
34 }
35
38 - // Already a WebSocket URL
39 - if strings.HasPrefix(server, "ws://") || strings.HasPrefix(server, "wss://") {
40 - return server, nil
36 + // Accept host:port input.
37 + if !strings.Contains(server, "://") {
38 + server = "http://" + server
39 }
40
43 - // HTTP/HTTPS -> WS/WSS with default /relay path
44 - if strings.HasPrefix(server, "http://") || strings.HasPrefix(server, "https://") {
45 - u, err := url.Parse(server)
46 - if err != nil {
47 - return "", fmt.Errorf("invalid bootstrap server %q: %w", raw, err)
48 - }
49 - switch u.Scheme {
50 - case "http":
51 - u.Scheme = "ws"
52 - case "https":
53 - u.Scheme = "wss"
54 - }
55 - if u.Path == "" || u.Path == "/" {
56 - u.Path = "/relay"
57 - }
58 - return u.String(), nil
59 - }
60 -
61 - // Bare host[:port][/path] -> assume WSS and /relay if no path
62 - u, err := url.Parse("wss://" + server)
41 + u, err := url.Parse(server)
42 if err != nil {
43 return "", fmt.Errorf("invalid bootstrap server %q: %w", raw, err)
44 }
45 if u.Host == "" {
46 return "", fmt.Errorf("invalid bootstrap server %q: missing host", raw)
47 }
69 - if u.Path == "" || u.Path == "/" {
70 - u.Path = "/relay"
48 +
49 + switch u.Scheme {
50 + case "http", "https":
51 + default:
52 + return "", fmt.Errorf("invalid bootstrap server %q: unsupported scheme %q (use http/https)", raw, u.Scheme)
53 + }
54 +
55 + if p := strings.TrimSpace(u.Path); p != "" && p != "/" {
56 + return "", fmt.Errorf("invalid bootstrap server %q: path is not allowed", raw)
57 }
72 - return u.String(), nil
58 +
59 + u.Path = ""
60 + u.RawQuery = ""
61 + u.Fragment = ""
62 + return strings.TrimSuffix(u.String(), "/"), nil
63 }
64
65 // ParseURLs splits a comma-separated string into a list of trimmed, non-empty URLs.
@@ -183,24 +173,85 @@ func DefaultAppPattern(base string) string {
173 return "*." + host
174 }
175
186 -// DefaultBootstrapFrom derives a websocket bootstrap URL from a base portal URL or host.
176 +// DefaultBootstrapFrom derives a relay API bootstrap URL from a base portal URL or host.
177 // It prefers NormalizePortalURL for consistent mapping and falls back to localhost.
178 // Examples:
189 -// - "https://portal.example.com" -> "wss://portal.example.com/relay"
190 -// - "http://portal.example.com" -> "ws://portal.example.com/relay"
191 -// - "localhost:4017" -> "wss://localhost:4017/relay"
192 -// - "" -> "ws://localhost:4017/relay"
179 +// - "https://portal.example.com" -> "https://portal.example.com"
180 +// - "http://portal.example.com" -> "http://portal.example.com"
181 +// - "localhost:4017" -> "http://localhost:4017"
182 +// - "" -> "http://localhost:4017"
183 func DefaultBootstrapFrom(base string) string {
184 base = strings.TrimSpace(base)
185 if base == "" {
196 - return "ws://localhost:4017/relay"
186 + return "http://localhost:4017"
187 }
188 if u, err := NormalizePortalURL(base); err == nil && u != "" {
189 return u
190 }
201 - host := StripScheme(strings.TrimSuffix(base, "/"))
191 +
192 + // Fallback for non-standard input while keeping api-base format.
193 + if strings.Contains(base, "://") {
194 + return "http://localhost:4017"
195 + }
196 + u, err := url.Parse("http://" + strings.TrimSuffix(base, "/"))
197 + if err != nil || u.Host == "" {
198 + return "http://localhost:4017"
199 + }
200 + u.Path = ""
201 + u.RawQuery = ""
202 + u.Fragment = ""
203 + return strings.TrimSuffix(u.String(), "/")
204 +}
205 +
206 +// PortalHostPort returns normalized host[:port] from a portal URL-like input.
207 +// Examples:
208 +// - "https://Portal.Example.com" -> "portal.example.com"
209 +// - "http://portal.example.com:4017" -> "portal.example.com:4017"
210 +func PortalHostPort(portalURL string) string {
211 + return strings.ToLower(strings.TrimSpace(
212 + StripWildCard(StripScheme(portalURL)),
213 + ))
214 +}
215 +
216 +// PortalBaseHostNoPort returns host without port from a portal URL-like input.
217 +// Examples:
218 +// - "https://portal.example.com:4017" -> "portal.example.com"
219 +func PortalBaseHostNoPort(portalURL string) string {
220 + return strings.ToLower(strings.TrimSpace(StripPort(PortalHostPort(portalURL))))
221 +}
222 +
223 +// ServicePublicURL returns a service URL derived from portalURL and service name.
224 +// Examples:
225 +// - portalURL: "https://portal.example.com", serviceName: "demo"
226 +// -> "https://demo.portal.example.com"
227 +func ServicePublicURL(portalURL, serviceName string) string {
228 + serviceName = strings.TrimSpace(serviceName)
229 + if serviceName == "" {
230 + return ""
231 + }
232 +
233 + raw := strings.TrimSpace(portalURL)
234 + if raw == "" {
235 + return ""
236 + }
237 + if !strings.Contains(raw, "://") {
238 + raw = "http://" + raw
239 + }
240 +
241 + u, err := url.Parse(raw)
242 + if err != nil || strings.TrimSpace(u.Host) == "" {
243 + return ""
244 + }
245 +
246 + host := strings.TrimSpace(StripWildCard(u.Host))
247 if host == "" {
203 - return "ws://localhost:4017/relay"
248 + return ""
249 + }
250 +
251 + scheme := strings.TrimSpace(u.Scheme)
252 + if scheme == "" {
253 + scheme = "http"
254 }
205 - return "ws://" + host + "/relay"
255 +
256 + return fmt.Sprintf("%s://%s.%s", scheme, serviceName, host)
257 }
utils/utils_test.go
+93 -36
@@ -82,50 +82,45 @@ func TestNormalizePortalURL(t *testing.T) {
82 want string
83 shouldFail bool
84 }{
85 - {
86 - name: "already ws",
87 - input: "ws://localhost:4017/relay",
88 - want: "ws://localhost:4017/relay",
89 - },
90 - {
91 - name: "already wss",
92 - input: "wss://localhost:4017/relay",
93 - want: "wss://localhost:4017/relay",
94 - },
85 {
86 name: "localhost with port",
87 input: "localhost:4017",
98 - want: "wss://localhost:4017/relay",
88 + want: "http://localhost:4017",
89 },
90 {
91 name: "domain without port",
92 input: "example.com",
103 - want: "wss://example.com/relay",
93 + want: "http://example.com",
94 },
95 {
96 name: "http scheme without path",
97 input: "http://example.com",
108 - want: "ws://example.com/relay",
98 + want: "http://example.com",
99 },
100 {
101 name: "https scheme without path",
102 input: "https://example.com",
113 - want: "wss://example.com/relay",
103 + want: "https://example.com",
104 },
105 {
116 - name: "http scheme with path",
117 - input: "http://example.com/custom",
118 - want: "ws://example.com/custom",
106 + name: "http scheme with path",
107 + input: "http://example.com/custom",
108 + shouldFail: true,
109 },
110 {
121 - name: "https scheme with path",
122 - input: "https://example.com/custom",
123 - want: "wss://example.com/custom",
111 + name: "https scheme with path",
112 + input: "https://example.com/custom",
113 + shouldFail: true,
114 },
115 {
126 - name: "bare host with path",
127 - input: "example.com/custom",
128 - want: "wss://example.com/custom",
116 + name: "unsupported ws scheme",
117 + input: "ws://example.com",
118 + shouldFail: true,
119 + },
120 + {
121 + name: "unsupported wss scheme",
122 + input: "wss://example.com",
123 + shouldFail: true,
124 },
125 {
126 name: "empty",
@@ -445,19 +440,17 @@ func TestDefaultBootstrapFrom(t *testing.T) {
440 input string
441 expected string
442 }{
448 - {"empty string", "", "ws://localhost:4017/relay"},
449 - {"whitespace", " ", "ws://localhost:4017/relay"},
450 - {"localhost with port", "localhost:4017", "wss://localhost:4017/relay"},
451 - {"https with domain", "https://portal.example.com", "wss://portal.example.com/relay"},
452 - {"http with domain", "http://portal.example.com", "ws://portal.example.com/relay"},
453 - {"ws scheme", "ws://example.com", "ws://example.com"},
454 - {"wss scheme", "wss://example.com", "wss://example.com"},
455 - {"ws with path", "ws://example.com/relay", "ws://example.com/relay"},
456 - {"wss with path", "wss://example.com/relay", "wss://example.com/relay"},
457 - {"domain only", "example.com", "wss://example.com/relay"},
458 - {"with trailing slash", "example.com/", "wss://example.com/relay"},
459 - {"with path", "example.com/custom", "wss://example.com/custom"},
460 - {"edge case invalid url", "://invalid", "wss://://invalid"},
443 + {"empty string", "", "http://localhost:4017"},
444 + {"whitespace", " ", "http://localhost:4017"},
445 + {"localhost with port", "localhost:4017", "http://localhost:4017"},
446 + {"https with domain", "https://portal.example.com", "https://portal.example.com"},
447 + {"http with domain", "http://portal.example.com", "http://portal.example.com"},
448 + {"ws scheme", "ws://example.com", "http://localhost:4017"},
449 + {"wss scheme", "wss://example.com", "http://localhost:4017"},
450 + {"domain only", "example.com", "http://example.com"},
451 + {"with trailing slash", "example.com/", "http://example.com"},
452 + {"with path", "example.com/custom", "http://example.com"},
453 + {"edge case invalid url", "://invalid", "http://localhost:4017"},
454 }
455
456 for _, tt := range tests {
@@ -467,3 +460,67 @@ func TestDefaultBootstrapFrom(t *testing.T) {
460 })
461 }
462 }
463 +
464 +func TestPortalHostPort(t *testing.T) {
465 + tests := []struct {
466 + name string
467 + input string
468 + expected string
469 + }{
470 + {"https lowercase", "https://Portal.Example.com", "portal.example.com"},
471 + {"with port", "http://portal.example.com:4017", "portal.example.com:4017"},
472 + {"wildcard", "https://*.portal.example.com", "portal.example.com"},
473 + {"bare host", "portal.example.com", "portal.example.com"},
474 + }
475 +
476 + for _, tt := range tests {
477 + t.Run(tt.name, func(t *testing.T) {
478 + assert.Equal(t, tt.expected, PortalHostPort(tt.input))
479 + })
480 + }
481 +}
482 +
483 +func TestPortalBaseHostNoPort(t *testing.T) {
484 + tests := []struct {
485 + name string
486 + input string
487 + expected string
488 + }{
489 + {"with port", "https://portal.example.com:4017", "portal.example.com"},
490 + {"no port", "https://portal.example.com", "portal.example.com"},
491 + {"localhost", "localhost:4017", "localhost"},
492 + }
493 +
494 + for _, tt := range tests {
495 + t.Run(tt.name, func(t *testing.T) {
496 + assert.Equal(t, tt.expected, PortalBaseHostNoPort(tt.input))
497 + })
498 + }
499 +}
500 +
501 +func TestServicePublicURL(t *testing.T) {
502 + tests := []struct {
503 + name string
504 + portalURL string
505 + service string
506 + expected string
507 + shouldFail bool
508 + }{
509 + {"https url", "https://portal.example.com", "demo", "https://demo.portal.example.com", false},
510 + {"http url", "http://portal.example.com:4017", "demo", "http://demo.portal.example.com:4017", false},
511 + {"bare host", "portal.example.com", "demo", "http://demo.portal.example.com", false},
512 + {"empty service", "https://portal.example.com", "", "", true},
513 + {"empty portal", "", "demo", "", true},
514 + }
515 +
516 + for _, tt := range tests {
517 + t.Run(tt.name, func(t *testing.T) {
518 + got := ServicePublicURL(tt.portalURL, tt.service)
519 + if tt.shouldFail {
520 + assert.Equal(t, "", got)
521 + return
522 + }
523 + assert.Equal(t, tt.expected, got)
524 + })
525 + }
526 +}