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