simplfy routing wip

Kim committed Nov 19, 2025 at 10:59 UTC 782beac12b91ef56cc80f4ae4870595dfc366cdd
5 files changed +251 -226
cmd/relay-server/main.go
+1 -8
@@ -25,7 +25,6 @@ var (
25 flagPort int
26 flagMaxLease int
27 flagLeaseBPS int
28 - rootHost string
28 )
29
30 func main() {
@@ -55,10 +54,7 @@ func main() {
54 flag.Parse()
55
56 flagBootstraps = sdk.ParseURLs(flagBootstrapsCSV)
58 - flagPortalURL = sdk.StripScheme(flagPortalURL)
57 flagPortalSubdomainURL = sdk.StripScheme(flagPortalSubdomainURL)
60 - rootHost = sdk.StripPort(flagPortalURL)
61 -
58 if err := runServer(); err != nil {
59 log.Fatal().Err(err).Msg("execute root command")
60 }
@@ -69,7 +65,6 @@ func runServer() error {
65 defer stop()
66
67 log.Info().
72 - Str("root_host", rootHost).
68 Str("frontend_base_url", flagPortalURL).
69 Str("subdomain_pattern", flagPortalSubdomainURL).
70 Str("bootstrap_uris", strings.Join(flagBootstraps, ",")).
@@ -78,7 +73,6 @@ func runServer() error {
73 cred := sdk.NewCredential()
74
75 serv := portal.NewRelayServer(cred, flagBootstraps)
81 - // Apply traffic controls if configured
76 if flagMaxLease > 0 {
77 serv.SetMaxRelayedPerLease(flagMaxLease)
78 }
@@ -88,8 +82,7 @@ func runServer() error {
82 serv.Start()
83 defer serv.Stop()
84
91 - // App UI + Relay + Static Frontend
92 - httpSrv := serveHTTP(ctx, fmt.Sprintf(":%d", flagPort), serv, cred.ID(), flagBootstraps, stop)
85 + httpSrv := serveHTTP(fmt.Sprintf(":%d", flagPort), serv, cred.ID(), flagBootstraps, stop)
86
87 <-ctx.Done()
88 log.Info().Msg("[server] shutting down...")
cmd/relay-server/serve.go renamed
+131 -178
@@ -3,6 +3,7 @@ package main
3 import (
4 "encoding/json"
5 "net/http"
6 + "path"
7 pathpkg "path"
8 "strconv"
9 "strings"
@@ -13,6 +14,136 @@ import (
14 "gosuda.org/portal/sdk"
15 )
16
17 +func serveAsset(mux *http.ServeMux, route, assetPath, contentType string) {
18 + mux.HandleFunc(route, func(w http.ResponseWriter, r *http.Request) {
19 + // Read from dist/app subdirectory of the embedded FS
20 + fullPath := path.Join("dist", "app", assetPath)
21 + b, err := distFS.ReadFile(fullPath)
22 + if err != nil {
23 + http.NotFound(w, r)
24 + return
25 + }
26 + if contentType != "" {
27 + w.Header().Set("Content-Type", contentType)
28 + }
29 + w.WriteHeader(http.StatusOK)
30 + _, _ = w.Write(b)
31 + })
32 +}
33 +
34 +// servePortalHTMLWithSSR serves portal.html with SSR data injection
35 +func servePortalHTMLWithSSR(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer) {
36 + sdk.SetCORSHeaders(w)
37 +
38 + // Read portal.html from embedded FS
39 + fullPath := pathpkg.Join("dist", "app", "portal.html")
40 + htmlContent, err := distFS.ReadFile(fullPath)
41 + if err != nil {
42 + log.Error().Err(err).Msg("Failed to read portal.html")
43 + http.NotFound(w, r)
44 + return
45 + }
46 +
47 + // Inject SSR data
48 + injectedHTML := injectServerData(string(htmlContent), serv)
49 +
50 + // Set headers
51 + w.Header().Set("Content-Type", "text/html; charset=utf-8")
52 + w.Header().Set("Cache-Control", "no-cache, must-revalidate")
53 +
54 + // Send response
55 + w.WriteHeader(http.StatusOK)
56 + w.Write([]byte(injectedHTML))
57 +
58 + log.Debug().Msg("Served portal.html with SSR data")
59 +}
60 +
61 +// injectServerData injects server data into HTML for SSR
62 +func injectServerData(htmlContent string, serv *portal.RelayServer) string {
63 + // Get server data from lease manager
64 + rows := convertLeaseEntriesToRows(serv)
65 +
66 + // Marshal to JSON
67 + jsonData, err := json.Marshal(rows)
68 + if err != nil {
69 + log.Error().Err(err).Msg("Failed to marshal server data for SSR")
70 + jsonData = []byte("[]")
71 + }
72 +
73 + // Create SSR script tag
74 + ssrScript := `<script id="__SSR_DATA__" type="application/json">` + string(jsonData) + `</script>`
75 +
76 + // Inject before </head> tag
77 + injected := strings.Replace(htmlContent, "</head>", ssrScript+"\n</head>", 1)
78 +
79 + log.Debug().
80 + Int("rows", len(rows)).
81 + Int("jsonSize", len(jsonData)).
82 + Msg("Injected SSR data into HTML")
83 +
84 + return injected
85 +}
86 +
87 +// servePortalStaticFile serves static files for portal frontend with caching
88 +func servePortalStaticFile(w http.ResponseWriter, r *http.Request, filePath string) {
89 + // Check if this is a content-addressed WASM file
90 + if strings.HasSuffix(filePath, ".wasm") {
91 + hash := strings.TrimSuffix(filePath, ".wasm")
92 + if sdk.IsHexString(hash) {
93 + serveCompressedWasm(w, r, filePath)
94 + return
95 + }
96 + }
97 +
98 + // Regular static file serving
99 + w.Header().Set("Cache-Control", "public, max-age=3600")
100 + serveStaticFile(w, r, filePath, "")
101 +}
102 +
103 +// serveAppStatic serves static files for app UI (React app) from embedded FS
104 +// Falls back to portal.html with SSR when path is root or file not found
105 +func serveAppStatic(w http.ResponseWriter, r *http.Request, path string, serv *portal.RelayServer) {
106 + // Prevent directory traversal
107 + if strings.Contains(path, "..") {
108 + http.Error(w, "Invalid path", http.StatusBadRequest)
109 + return
110 + }
111 +
112 + sdk.SetCORSHeaders(w)
113 +
114 + // If path is empty or "/", serve portal.html with SSR
115 + if path == "" || path == "/" {
116 + servePortalHTMLWithSSR(w, r, serv)
117 + return
118 + }
119 +
120 + // Try to read from embedded FS
121 + fullPath := pathpkg.Join("dist", "app", path)
122 + data, err := distFS.ReadFile(fullPath)
123 + if err != nil {
124 + // File not found - fallback to portal.html with SSR for SPA routing
125 + log.Debug().Err(err).Str("path", path).Msg("app static file not found, falling back to SSR")
126 + servePortalHTMLWithSSR(w, r, serv)
127 + return
128 + }
129 +
130 + // Set content type based on extension
131 + ext := pathpkg.Ext(path)
132 + contentType := sdk.GetContentType(ext)
133 + if contentType != "" {
134 + w.Header().Set("Content-Type", contentType)
135 + }
136 +
137 + w.Header().Set("Cache-Control", "public, max-age=3600")
138 + w.WriteHeader(http.StatusOK)
139 + w.Write(data)
140 +
141 + log.Debug().
142 + Str("path", path).
143 + Int("size", len(data)).
144 + Msg("served app static file")
145 +}
146 +
147 // wasmCache stores pre-loaded WASM files in memory (optional)
148 type wasmCacheEntry struct {
149 brotli []byte
@@ -92,121 +223,6 @@ func cacheWasmFile(name, fullPath string) error {
223 return nil
224 }
225
95 -// createPortalMux creates a new HTTP mux for portal frontend
96 -func createPortalMux() *http.ServeMux {
97 - // Initialize WASM cache on startup
98 - if err := initWasmCache(); err != nil {
99 - log.Error().Err(err).Msg("failed to initialize WASM cache")
100 - }
101 -
102 - mux := http.NewServeMux()
103 -
104 - // Static file handler for /frontend/ (for unified caching)
105 - mux.HandleFunc("/frontend/", func(w http.ResponseWriter, r *http.Request) {
106 - sdk.SetCORSHeaders(w)
107 - if r.Method == http.MethodOptions {
108 - w.WriteHeader(http.StatusOK)
109 - return
110 - }
111 - path := strings.TrimPrefix(r.URL.Path, "/frontend/")
112 -
113 - // Special handling for manifest.json - generate dynamically
114 - if path == "manifest.json" {
115 - serveDynamicManifest(w)
116 - return
117 - }
118 -
119 - servePortalStaticFile(w, r, path)
120 - })
121 -
122 - // Root handler for portal frontend
123 - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
124 - sdk.SetCORSHeaders(w)
125 - if r.Method == http.MethodOptions {
126 - w.WriteHeader(http.StatusOK)
127 - return
128 - }
129 - if r.URL.Path == "/" {
130 - serveStaticFile(w, r, "portal.html", "text/html; charset=utf-8")
131 - return
132 - }
133 -
134 - // Try to serve static files, fallback to portal.html for SPA routing
135 - servePortalStatic(w, r)
136 - })
137 -
138 - return mux
139 -}
140 -
141 -// servePortalHTMLWithSSR serves portal.html with SSR data injection
142 -func servePortalHTMLWithSSR(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer) {
143 - sdk.SetCORSHeaders(w)
144 -
145 - // Read portal.html from embedded FS
146 - fullPath := pathpkg.Join("dist", "app", "portal.html")
147 - htmlContent, err := distFS.ReadFile(fullPath)
148 - if err != nil {
149 - log.Error().Err(err).Msg("Failed to read portal.html")
150 - http.NotFound(w, r)
151 - return
152 - }
153 -
154 - // Inject SSR data
155 - injectedHTML := injectServerData(string(htmlContent), serv)
156 -
157 - // Set headers
158 - w.Header().Set("Content-Type", "text/html; charset=utf-8")
159 - w.Header().Set("Cache-Control", "no-cache, must-revalidate")
160 -
161 - // Send response
162 - w.WriteHeader(http.StatusOK)
163 - w.Write([]byte(injectedHTML))
164 -
165 - log.Debug().Msg("Served portal.html with SSR data")
166 -}
167 -
168 -// injectServerData injects server data into HTML for SSR
169 -func injectServerData(htmlContent string, serv *portal.RelayServer) string {
170 - // Get server data from lease manager
171 - rows := convertLeaseEntriesToRows(serv)
172 -
173 - // Marshal to JSON
174 - jsonData, err := json.Marshal(rows)
175 - if err != nil {
176 - log.Error().Err(err).Msg("Failed to marshal server data for SSR")
177 - jsonData = []byte("[]")
178 - }
179 -
180 - // Create SSR script tag
181 - ssrScript := `<script id="__SSR_DATA__" type="application/json">` + string(jsonData) + `</script>`
182 -
183 - // Inject before </head> tag
184 - injected := strings.Replace(htmlContent, "</head>", ssrScript+"\n</head>", 1)
185 -
186 - log.Debug().
187 - Int("rows", len(rows)).
188 - Int("jsonSize", len(jsonData)).
189 - Msg("Injected SSR data into HTML")
190 -
191 - return injected
192 -}
193 -
194 -// servePortalStaticFile serves static files for portal frontend with caching
195 -func servePortalStaticFile(w http.ResponseWriter, r *http.Request, filePath string) {
196 - // Check if this is a content-addressed WASM file
197 - if strings.HasSuffix(filePath, ".wasm") {
198 - hash := strings.TrimSuffix(filePath, ".wasm")
199 - if sdk.IsHexString(hash) {
200 - serveCompressedWasm(w, r, filePath)
201 - return
202 - }
203 - }
204 -
205 - // Regular static file serving
206 - w.Header().Set("Cache-Control", "public, max-age=3600")
207 - serveStaticFile(w, r, filePath, "")
208 -}
209 -
226 // serveCompressedWasm serves pre-compressed WASM files from memory cache
227 func serveCompressedWasm(w http.ResponseWriter, r *http.Request, filePath string) {
228 wasmCacheMu.RLock()
@@ -267,50 +283,6 @@ func serveCompressedWasm(w http.ResponseWriter, r *http.Request, filePath string
283 Msg("served compressed WASM")
284 }
285
270 -// serveAppStatic serves static files for app UI (React app) from embedded FS
271 -// Falls back to portal.html with SSR when path is root or file not found
272 -func serveAppStatic(w http.ResponseWriter, r *http.Request, path string, serv *portal.RelayServer) {
273 - // Prevent directory traversal
274 - if strings.Contains(path, "..") {
275 - http.Error(w, "Invalid path", http.StatusBadRequest)
276 - return
277 - }
278 -
279 - sdk.SetCORSHeaders(w)
280 -
281 - // If path is empty or "/", serve portal.html with SSR
282 - if path == "" || path == "/" {
283 - servePortalHTMLWithSSR(w, r, serv)
284 - return
285 - }
286 -
287 - // Try to read from embedded FS
288 - fullPath := pathpkg.Join("dist", "app", path)
289 - data, err := distFS.ReadFile(fullPath)
290 - if err != nil {
291 - // File not found - fallback to portal.html with SSR for SPA routing
292 - log.Debug().Err(err).Str("path", path).Msg("app static file not found, falling back to SSR")
293 - servePortalHTMLWithSSR(w, r, serv)
294 - return
295 - }
296 -
297 - // Set content type based on extension
298 - ext := pathpkg.Ext(path)
299 - contentType := sdk.GetContentType(ext)
300 - if contentType != "" {
301 - w.Header().Set("Content-Type", contentType)
302 - }
303 -
304 - w.Header().Set("Cache-Control", "public, max-age=3600")
305 - w.WriteHeader(http.StatusOK)
306 - w.Write(data)
307 -
308 - log.Debug().
309 - Str("path", path).
310 - Int("size", len(data)).
311 - Msg("served app static file")
312 -}
313 -
286 // servePortalStatic serves static files for portal frontend with appropriate cache headers
287 // Falls back to portal.html for SPA routing (404 -> portal.html)
288 func servePortalStatic(w http.ResponseWriter, r *http.Request) {
@@ -418,25 +390,6 @@ func serveStaticFileWithFallback(w http.ResponseWriter, r *http.Request, path st
390 w.Write(data)
391 }
392
421 -// isPortalSubdomain checks if the host matches the portal frontend pattern
422 -func isPortalSubdomain(host string) bool {
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 -
432 - // Fallback to checking if it ends with .{rootHost}
433 - if rootHost == "" {
434 - return false
435 - }
436 -
437 - return strings.HasSuffix(sdk.StripPort(host), "."+rootHost)
438 -}
439 -
393 // serveDynamicManifest generates and serves manifest.json dynamically
394 func serveDynamicManifest(w http.ResponseWriter) {
395 sdk.SetCORSHeaders(w)
cmd/relay-server/view.go
+52 -26
@@ -6,7 +6,6 @@ import (
6 "encoding/json"
7 "fmt"
8 "net/http"
9 - "path"
9 "strings"
10 "time"
11
@@ -19,29 +18,17 @@ import (
18 //go:embed dist/*
19 var distFS embed.FS
20
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
25 - fullPath := path.Join("dist", "app", assetPath)
26 - b, err := distFS.ReadFile(fullPath)
27 - if err != nil {
28 - http.NotFound(w, r)
29 - return
30 - }
31 - if contentType != "" {
32 - w.Header().Set("Content-Type", contentType)
33 - }
34 - w.WriteHeader(http.StatusOK)
35 - _, _ = w.Write(b)
36 - })
37 -}
38 -
21 // serveHTTP builds the HTTP mux and returns the server.
40 -func serveHTTP(_ context.Context, addr string, serv *portal.RelayServer, nodeID string, bootstraps []string, cancel context.CancelFunc) *http.Server {
22 +func serveHTTP(addr string, serv *portal.RelayServer, nodeID string, bootstraps []string, cancel context.CancelFunc) *http.Server {
23 if addr == "" {
24 addr = ":0"
25 }
26
27 + // Initialize WASM cache used by content handlers
28 + if err := initWasmCache(); err != nil {
29 + log.Error().Err(err).Msg("failed to initialize WASM cache")
30 + }
31 +
32 // Create app UI mux
33 appMux := http.NewServeMux()
34
@@ -108,12 +95,49 @@ func serveHTTP(_ context.Context, addr string, serv *portal.RelayServer, nodeID
95 w.Write([]byte("{\"status\":\"ok\"}"))
96 })
97
111 - // Create portal frontend mux
112 - portalMux := createPortalMux()
98 + // Create portal frontend mux (routes only)
99 + portalMux := http.NewServeMux()
100 +
101 + // Static file handler for /frontend/ (for unified caching)
102 + portalMux.HandleFunc("/frontend/", func(w http.ResponseWriter, r *http.Request) {
103 + sdk.SetCORSHeaders(w)
104 + if r.Method == http.MethodOptions {
105 + w.WriteHeader(http.StatusOK)
106 + return
107 + }
108 + p := strings.TrimPrefix(r.URL.Path, "/frontend/")
109 + if p == "manifest.json" {
110 + serveDynamicManifest(w)
111 + return
112 + }
113 + servePortalStaticFile(w, r, p)
114 + })
115 +
116 + // Service worker for portal subdomains (serve from dist/wasm)
117 + portalMux.HandleFunc("/service-worker.js", func(w http.ResponseWriter, r *http.Request) {
118 + serveDynamicServiceWorker(w, r)
119 + })
120 +
121 + // Root and SPA fallback for portal subdomains
122 + portalMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
123 + sdk.SetCORSHeaders(w)
124 + if r.Method == http.MethodOptions {
125 + w.WriteHeader(http.StatusOK)
126 + return
127 + }
128 + if r.URL.Path == "/" {
129 + // Serve portal HTML from dist/wasm
130 + serveStaticFile(w, r, "portal.html", "text/html; charset=utf-8")
131 + return
132 + }
133 + servePortalStatic(w, r)
134 + })
135
114 - // Top-level handler that routes based on host and path
115 - topHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
116 - if isPortalSubdomain(r.Host) {
136 + // routes based on host and path
137 + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
138 + // Route subdomain requests (e.g., *.example.com) to portalMux
139 + // and everything else to the app UI mux.
140 + if sdk.IsSubdomain(flagPortalSubdomainURL, r.Host) {
141 portalMux.ServeHTTP(w, r)
142 } else {
143 appMux.ServeHTTP(w, r)
@@ -122,7 +146,7 @@ func serveHTTP(_ context.Context, addr string, serv *portal.RelayServer, nodeID
146
147 srv := &http.Server{
148 Addr: addr,
125 - Handler: topHandler,
149 + Handler: handler,
150 }
151
152 go func() {
@@ -242,7 +266,9 @@ func convertLeaseEntriesToRows(serv *portal.RelayServer) []leaseRow {
266 dnsLabel = dnsLabel[:8] + "..."
267 }
268
245 - link := fmt.Sprintf("//%s.%s/", lease.Name, flagPortalURL)
269 + // Build link using the configured subdomain base (strip "*." if present)
270 + subdomainBase := strings.TrimPrefix(sdk.StripScheme(flagPortalSubdomainURL), "*.")
271 + link := fmt.Sprintf("//%s.%s/", lease.Name, subdomainBase)
272
273 row := leaseRow{
274 Peer: identityID,
sdk/utils.go
+32 -14
@@ -203,25 +203,43 @@ func SetCORSHeaders(w http.ResponseWriter) {
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
206 +// IsSubdomain reports whether host matches the given domain pattern.
207 +// Supports patterns like:
208 +// - "*.example.com" (wildcard for any subdomain of example.com)
209 +// - "sub.example.com" (exact host match)
210 +//
211 +// Normalizes by stripping scheme/port and lowercasing.
212 +func IsSubdomain(domain, host string) bool {
213 + if host == "" || domain == "" {
214 + return false
215 }
211 - s = strings.TrimPrefix(s, "http://")
212 - s = strings.TrimPrefix(s, "https://")
216
214 - return s
215 -}
217 + h := strings.ToLower(StripPort(StripScheme(host)))
218 + d := strings.ToLower(StripPort(StripScheme(domain)))
219
217 -// TrimAfterFirstSlash returns s up to but not including the first '/'.
218 -func TrimAfterFirstSlash(s string) string {
219 - if s == "" {
220 - return s
220 + // Wildcard pattern: require at least one label before the suffix
221 + if strings.HasPrefix(d, "*.") {
222 + suffix := d[1:] // keep leading dot (e.g., ".example.com")
223 + return len(h) > len(suffix) && strings.HasSuffix(h, suffix)
224 }
222 - if idx := strings.IndexByte(s, '/'); idx >= 0 {
223 - return s[:idx]
225 +
226 + if h == d {
227 + return true
228 + }
229 +
230 + if strings.Count(d, ".") == 1 {
231 + return strings.HasSuffix(h, "."+d)
232 }
233 +
234 + return false
235 +}
236 +
237 +func StripScheme(s string) string {
238 + s = strings.TrimSpace(s)
239 + s = strings.TrimSuffix(s, "/")
240 + s = strings.TrimPrefix(s, "http://")
241 + s = strings.TrimPrefix(s, "https://")
242 +
243 return s
244 }
245
sdk/utils_test.go
+35
@@ -223,3 +223,38 @@ func TestIsHexString(t *testing.T) {
223 assert.False(t, IsHexString("g"))
224 assert.False(t, IsHexString("xyz"))
225 }
226 +
227 +func TestIsSubdomain(t *testing.T) {
228 + tests := []struct {
229 + name string
230 + pattern string
231 + host string
232 + want bool
233 + }{
234 + {"wildcard basic", "*.example.com", "api.example.com", true},
235 + {"wildcard deep", "*.example.com", "v1.api.example.com", true},
236 + {"wildcard requires label", "*.example.com", "example.com", false},
237 + {"wildcard mismatch", "*.example.com", "example.org", false},
238 +
239 + {"exact match", "sub.example.com", "sub.example.com", true},
240 + {"exact mismatch sub-sub", "sub.example.com", "deep.sub.example.com", false},
241 + {"exact case+port insensitive", "SuB.ExAmPlE.CoM", "SUB.example.com:443", true},
242 +
243 + {"base domain exact", "example.com", "example.com", true},
244 + {"base domain includes subdomains", "example.com", "api.example.com", true},
245 + {"base domain mismatch suffix", "example.com", "badexample.com", false},
246 +
247 + {"empty pattern", "", "a.example.com", false},
248 +
249 + {"localhost wildcard", "*.localhost", "a.localhost", true},
250 + {"localhost wildcard with port", "*.localhost:4017", "a.localhost:4017", true},
251 + {"scheme+port normalized", "https://*.example.com:443", "api.example.com:443", true},
252 + }
253 +
254 + for _, tc := range tests {
255 + t.Run(tc.name, func(t *testing.T) {
256 + got := IsSubdomain(tc.pattern, tc.host)
257 + assert.Equal(t, got, tc.want, tc.name)
258 + })
259 + }
260 +}