relay-server: tidy configs wip

Kim committed Nov 18, 2025 at 19:05 UTC 897db660d2bffbe3aabfa2a2f13cfc607b766116
7 files changed +99 -127
Dockerfile
+2 -2
@@ -25,8 +25,8 @@ FROM gcr.io/distroless/static-debian12:nonroot
25
26 COPY --from=builder /src/bin/relay-server /usr/bin/relay-server
27
28 -ENV PORTAL_UI_URL=http://localhost:4017
29 -ENV PORTAL_FRONTEND_URL=http://*.localhost:4017
28 +ENV PORTAL_URL=http://localhost:4017
29 +ENV PORTAL_SUBDOMAIN_URL=http://*.localhost:4017
30 ENV BOOTSTRAP_URIS=ws://localhost:4017/relay
31
32 EXPOSE 4017
README.md
+3 -3
@@ -48,9 +48,9 @@ http://localhost:4017
48 # A (wildcard) for *.example.com (or *.portal.example.com) → server IP
49 #
50 # Then edit docker-compose.yml environment for your domain:
51 -PORTAL_UI_URL: https://yourservice.com
52 -PORTAL_FRONTEND_URL: https://*.yourservice.com
53 -BOOTSTRAP_URIS: wss://yourservice.com/relay
51 +PORTAL_URL: https://portal.example.com
52 +PORTAL_SUBDOMAIN_URL: https://*.example.com
53 +BOOTSTRAP_URIS: wss://portal.example.com/relay
54
55 ```
56
cmd/relay-server/main.go
+25 -62
@@ -18,53 +18,46 @@ import (
18 )
19
20 var (
21 - flagBootstraps []string
22 - flagALPN string
23 - flagPort int
24 - flagPortalHost string
25 - flagMaxLease int
26 - flagLeaseBPS int
21 + flagPortalURL string
22 + flagPortalSubdomainURL string
23 + flagBootstraps []string
24 + flagALPN string
25 + flagPort int
26 + flagMaxLease int
27 + flagLeaseBPS int
28 + rootHost string
29 )
30
31 func main() {
32 log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339})
33
32 - // Parse PORTAL_UI_URL or PORTAL_FRONTEND_URL to extract portal host
33 - defaultPortalHost := os.Getenv("PORTAL_UI_URL")
34 - if defaultPortalHost == "" {
35 - defaultPortalHost = os.Getenv("PORTAL_FRONTEND_URL")
34 + defaultPortalURL := strings.TrimSuffix(os.Getenv("PORTAL_URL"), "/")
35 + if defaultPortalURL == "" {
36 + defaultPortalURL = "localhost:4017"
37 }
37 - if defaultPortalHost != "" {
38 - // Extract host from URL (supports wildcard patterns like http://*.localhost:4017)
39 - defaultPortalHost = strings.TrimPrefix(defaultPortalHost, "http://")
40 - defaultPortalHost = strings.TrimPrefix(defaultPortalHost, "https://")
41 - defaultPortalHost = strings.TrimPrefix(defaultPortalHost, "*.")
42 - } else {
43 - defaultPortalHost = "localhost:4017"
38 + defaultSubdomain := os.Getenv("PORTAL_SUBDOMAIN_URL")
39 + if defaultSubdomain == "" {
40 + defaultSubdomain = "*.localhost:4017"
41 }
42 defaultBootstraps := os.Getenv("BOOTSTRAP_URIS")
43 if defaultBootstraps == "" {
44 defaultBootstraps = "ws://localhost:4017/relay"
45 }
46 +
47 var flagBootstrapsCSV string
48 + flag.StringVar(&flagPortalURL, "portal-url", defaultPortalURL, "base URL for portal frontend (env: PORTAL_URL)")
49 + flag.StringVar(&flagPortalSubdomainURL, "portal-subdomain-url", defaultSubdomain, "subdomain wildcard URL (env: PORTAL_SUBDOMAIN_URL)")
50 flag.StringVar(&flagBootstrapsCSV, "bootstraps", defaultBootstraps, "bootstrap addresses (comma-separated)")
51 flag.StringVar(&flagALPN, "alpn", "http/1.1", "ALPN identifier for this service")
52 flag.IntVar(&flagPort, "port", 4017, "app UI and HTTP proxy port")
53 - flag.StringVar(&flagPortalHost, "portal-host", defaultPortalHost, "portal host for frontend serving (env: PORTAL_HOST)")
53 flag.IntVar(&flagMaxLease, "max-lease", 0, "maximum active relayed connections per lease (0 = unlimited)")
54 flag.IntVar(&flagLeaseBPS, "lease-bps", 0, "default bytes-per-second limit per lease (0 = unlimited)")
56 -
55 flag.Parse()
56
59 - // Parse bootstrap list
60 - parts := strings.Split(flagBootstrapsCSV, ",")
61 - flagBootstraps = make([]string, 0, len(parts))
62 - for _, p := range parts {
63 - s := strings.TrimSpace(p)
64 - if s != "" {
65 - flagBootstraps = append(flagBootstraps, s)
66 - }
67 - }
57 + flagBootstraps = sdk.ParseURLs(flagBootstrapsCSV)
58 + flagPortalURL = sdk.StripScheme(flagPortalURL)
59 + flagPortalSubdomainURL = sdk.StripScheme(flagPortalSubdomainURL)
60 + rootHost = sdk.StripPort(flagPortalURL)
61
62 if err := runServer(); err != nil {
63 log.Fatal().Err(err).Msg("execute root command")
@@ -75,41 +68,11 @@ func runServer() error {
68 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
69 defer stop()
70
78 - // Set portal host
79 - portalHost = flagPortalHost
80 -
81 - // Set portal UI URL from environment or construct from portal host
82 - portalUIURL = os.Getenv("PORTAL_UI_URL")
83 - if portalUIURL == "" {
84 - portalUIURL = os.Getenv("PORTAL_FRONTEND_URL")
85 - }
86 - if portalUIURL == "" {
87 - portalUIURL = "http://" + portalHost
88 - }
89 - // Trim trailing slashes
90 - portalUIURL = strings.TrimSuffix(portalUIURL, "/")
91 -
92 - // Set portal frontend pattern from PORTAL_FRONTEND_URL
93 - portalFrontendURL := os.Getenv("PORTAL_FRONTEND_URL")
94 - if portalFrontendURL != "" {
95 - // Extract host pattern from URL (e.g., http://*.localhost:4017 -> *.localhost:4017)
96 - portalFrontendURL = strings.TrimPrefix(portalFrontendURL, "http://")
97 - portalFrontendURL = strings.TrimPrefix(portalFrontendURL, "https://")
98 - portalFrontendPattern = portalFrontendURL
99 - }
100 -
101 - // Set bootstrap URIs from environment
102 - bootstrapURIs = os.Getenv("BOOTSTRAP_URIS")
103 - if bootstrapURIs == "" {
104 - // Use flagBootstraps as fallback
105 - bootstrapURIs = strings.Join(flagBootstraps, ",")
106 - }
107 -
71 log.Info().
109 - Str("portal_host", portalHost).
110 - Str("portal_ui_url", portalUIURL).
111 - Str("portal_frontend_pattern", portalFrontendPattern).
112 - Str("bootstrap_uris", bootstrapURIs).
72 + Str("root_host", rootHost).
73 + Str("frontend_base_url", flagPortalURL).
74 + Str("subdomain_pattern", flagPortalSubdomainURL).
75 + Str("bootstrap_uris", strings.Join(flagBootstraps, ",")).
76 Msg("[server] frontend configuration")
77
78 cred := sdk.NewCredential()
cmd/relay-server/view.go
+10 -25
@@ -6,13 +6,12 @@ import (
6 "encoding/json"
7 "fmt"
8 "net/http"
9 + "path"
10 "strings"
11 "time"
12
13 "github.com/rs/zerolog/log"
14
14 - pathpkg "path"
15 -
15 "gosuda.org/portal/portal"
16 "gosuda.org/portal/sdk"
17 )
@@ -23,7 +22,7 @@ var distFS embed.FS
22 func serveAsset(mux *http.ServeMux, route, assetPath, contentType string) {
23 mux.HandleFunc(route, func(w http.ResponseWriter, r *http.Request) {
24 // Read from dist/app subdirectory of the embedded FS
26 - fullPath := pathpkg.Join("dist", "app", assetPath)
25 + fullPath := path.Join("dist", "app", assetPath)
26 b, err := distFS.ReadFile(fullPath)
27 if err != nil {
28 http.NotFound(w, r)
@@ -58,8 +57,8 @@ func serveHTTP(_ context.Context, addr string, serv *portal.RelayServer, nodeID
57 w.WriteHeader(http.StatusOK)
58 return
59 }
61 - path := strings.TrimPrefix(r.URL.Path, "/app/")
62 - serveAppStatic(w, r, path, serv)
60 + p := strings.TrimPrefix(r.URL.Path, "/app/")
61 + serveAppStatic(w, r, p, serv)
62 })
63
64 // Portal frontend files (for unified caching)
@@ -69,15 +68,13 @@ func serveHTTP(_ context.Context, addr string, serv *portal.RelayServer, nodeID
68 w.WriteHeader(http.StatusOK)
69 return
70 }
72 - path := strings.TrimPrefix(r.URL.Path, "/frontend/")
73 -
74 - // Special handling for manifest.json - generate dynamically
75 - if path == "manifest.json" {
71 + p := strings.TrimPrefix(r.URL.Path, "/frontend/")
72 + if p == "manifest.json" {
73 serveDynamicManifest(w)
74 return
75 }
76
80 - servePortalStaticFile(w, r, path)
77 + servePortalStaticFile(w, r, p)
78 })
79
80 appMux.HandleFunc("/relay", func(w http.ResponseWriter, r *http.Request) {
@@ -102,8 +99,8 @@ func serveHTTP(_ context.Context, addr string, serv *portal.RelayServer, nodeID
99 // App UI index page - serve React frontend with SSR (delegates to serveAppStatic)
100 appMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
101 // serveAppStatic handles both "/" and 404 fallback with SSR
105 - path := strings.TrimPrefix(r.URL.Path, "/")
106 - serveAppStatic(w, r, path, serv)
102 + p := strings.TrimPrefix(r.URL.Path, "/")
103 + serveAppStatic(w, r, p, serv)
104 })
105
106 appMux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
@@ -245,19 +242,7 @@ func convertLeaseEntriesToRows(serv *portal.RelayServer) []leaseRow {
242 dnsLabel = dnsLabel[:8] + "..."
243 }
244
248 - // Use frontend pattern if available, otherwise fall back to portalHost
249 - var link string
250 - if portalFrontendPattern != "" {
251 - // For wildcard patterns like *.localhost:4017, replace * with lease name
252 - if strings.HasPrefix(portalFrontendPattern, "*.") {
253 - link = fmt.Sprintf("//%s%s", lease.Name, strings.TrimPrefix(portalFrontendPattern, "*"))
254 - } else {
255 - // For non-wildcard patterns, construct URL with lease name as subdomain
256 - link = fmt.Sprintf("//%s.%s/", lease.Name, portalFrontendPattern)
257 - }
258 - } else {
259 - link = fmt.Sprintf("//%s.%s/", lease.Name, portalHost)
260 - }
245 + link := fmt.Sprintf("//%s.%s/", lease.Name, flagPortalURL)
246
247 row := leaseRow{
248 Peer: identityID,
cmd/relay-server/wasm.go
+15 -33
@@ -13,18 +13,6 @@ import (
13 "gosuda.org/portal/sdk"
14 )
15
16 -// portalHost is the host for portal frontend.
17 -var portalHost = "localhost"
18 -
19 -// portalUIURL is the base URL for portal frontend
20 -var portalUIURL = "http://localhost:4017"
21 -
22 -// portalFrontendPattern is the wildcard pattern for portal frontend URLs (e.g., *.localhost:4017)
23 -var portalFrontendPattern = ""
24 -
25 -// bootstrapURIs stores the relay bootstrap server URIs
26 -var bootstrapURIs = "ws://localhost:4017/relay"
27 -
16 // wasmCache stores pre-loaded WASM files in memory (optional)
17 type wasmCacheEntry struct {
18 brotli []byte
@@ -337,9 +325,8 @@ func servePortalStatic(w http.ResponseWriter, r *http.Request) {
325 // Special handling for specific files
326 switch path {
327 case "manifest.json":
340 - w.Header().Set("Cache-Control", "no-cache, must-revalidate")
341 - w.Header().Set("Content-Type", "application/json")
342 - serveStaticFileWithFallback(w, r, path, "application/json")
328 + // Serve dynamic manifest regardless of static presence
329 + serveDynamicManifest(w)
330 return
331
332 case "service-worker.js":
@@ -431,30 +418,25 @@ func serveStaticFileWithFallback(w http.ResponseWriter, r *http.Request, path st
418 w.Write(data)
419 }
420
434 -// getContentType returns the MIME type for a file extension
435 -// content types are provided via sdk.GetContentType
436 -
421 // isPortalSubdomain checks if the host matches the portal frontend pattern
422 func isPortalSubdomain(host string) bool {
439 - // If we have a frontend pattern, use it
440 - if portalFrontendPattern != "" {
441 - return sdk.MatchesWildcardPattern(host, portalFrontendPattern)
423 + // If we have a frontend pattern (already normalized in main), use it
424 + if flagPortalSubdomainURL != "" {
425 + p := flagPortalSubdomainURL
426 + if strings.HasPrefix(p, "*.") {
427 + return strings.HasSuffix(host, strings.TrimPrefix(p, "*"))
428 + }
429 + return host == p
430 }
431
444 - // Fallback to checking if it ends with .{portalHost}
445 - if portalHost == "" {
432 + // Fallback to checking if it ends with .{rootHost}
433 + if rootHost == "" {
434 return false
435 }
436
449 - return strings.HasSuffix(host, "."+portalHost)
437 + return strings.HasSuffix(sdk.StripPort(host), "."+rootHost)
438 }
439
452 -// matchesWildcardPattern checks if a host matches a wildcard pattern (e.g., *.localhost:4017)
453 -// wildcard matching is provided via sdk.MatchesWildcardPattern
454 -
455 -// isHexString checks if a string contains only hexadecimal characters
456 -// hex string check is provided via sdk.IsHexString
457 -
440 // serveDynamicManifest generates and serves manifest.json dynamically
441 func serveDynamicManifest(w http.ResponseWriter) {
442 sdk.SetCORSHeaders(w)
@@ -493,14 +475,14 @@ func serveDynamicManifest(w http.ResponseWriter) {
475 }
476
477 // Generate WASM URL
496 - wasmURL := portalUIURL + "/frontend/" + wasmFile
478 + wasmURL := flagPortalURL + "/frontend/" + wasmFile
479
480 // Create manifest structure
481 manifest := map[string]string{
482 "wasmFile": wasmFile,
483 "wasmUrl": wasmURL,
484 "hash": wasmHash,
503 - "bootstraps": bootstrapURIs,
485 + "bootstraps": strings.Join(flagBootstraps, ","),
486 }
487
488 // Set headers for no caching
@@ -519,7 +501,7 @@ func serveDynamicManifest(w http.ResponseWriter) {
501 Str("wasmFile", wasmFile).
502 Str("wasmUrl", wasmURL).
503 Str("hash", wasmHash).
522 - Str("bootstraps", bootstrapURIs).
504 + Str("bootstraps", strings.Join(flagBootstraps, ",")).
505 Msg("Served dynamic manifest")
506 }
507
docker-compose.yml
+2 -2
@@ -8,8 +8,8 @@ services:
8 - "--port"
9 - "${PORTAL_PORT:-4017}"
10 environment:
11 - PORTAL_UI_URL: ${PORTAL_UI_URL:-http://localhost:${PORTAL_PORT:-4017}}
12 - PORTAL_FRONTEND_URL: ${PORTAL_FRONTEND_URL:-http://*.localhost:${PORTAL_PORT:-4017}}
11 + PORTAL_URL: ${PORTAL_URL:-http://localhost:${PORTAL_PORT:-4017}}
12 + PORTAL_SUBDOMAIN_URL: ${PORTAL_SUBDOMAIN_URL:-http://*.localhost:${PORTAL_PORT:-4017}}
13 BOOTSTRAP_URIS: ${BOOTSTRAP_URIS:-ws://localhost:${PORTAL_PORT:-4017}/relay}
14 ports:
15 - "4017:4017"
sdk/utils.go
+42
@@ -202,3 +202,45 @@ func SetCORSHeaders(w http.ResponseWriter) {
202 w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
203 w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Accept, Accept-Encoding")
204 }
205 +
206 +func StripScheme(s string) string {
207 + s = strings.TrimSpace(s)
208 + if s == "" {
209 + return s
210 + }
211 + s = strings.TrimPrefix(s, "http://")
212 + s = strings.TrimPrefix(s, "https://")
213 +
214 + return s
215 +}
216 +
217 +// TrimAfterFirstSlash returns s up to but not including the first '/'.
218 +func TrimAfterFirstSlash(s string) string {
219 + if s == "" {
220 + return s
221 + }
222 + if idx := strings.IndexByte(s, '/'); idx >= 0 {
223 + return s[:idx]
224 + }
225 + return s
226 +}
227 +
228 +func StripPort(s string) string {
229 + if s == "" {
230 + return s
231 + }
232 + if idx := strings.LastIndexByte(s, ':'); idx >= 0 && idx+1 < len(s) {
233 + port := s[idx+1:]
234 + digits := true
235 + for _, ch := range port {
236 + if ch < '0' || ch > '9' {
237 + digits = false
238 + break
239 + }
240 + }
241 + if digits {
242 + return s[:idx]
243 + }
244 + }
245 + return s
246 +}