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
+}