move files
Kim committed
Dec 12, 2025 at 18:24 UTC
0811f44ec3da0d2690af6d5db1d7ba91f31565a0
3 files changed
+818
-818
cmd/relay-server/frontend.go
new
+656
@@ -0,0 +1,656 @@
1
+package main
2
+
3
+import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "io/fs"
7
+ "net/http"
8
+ "path"
9
+ "strconv"
10
+ "strings"
11
+ "sync"
12
+ "time"
13
+
14
+ "github.com/rs/zerolog/log"
15
+ "gosuda.org/portal/cmd/relay-server/manager"
16
+ "gosuda.org/portal/portal"
17
+ "gosuda.org/portal/sdk"
18
+ "gosuda.org/portal/utils"
19
+)
20
+
21
+type readDirFileFS interface {
22
+ fs.ReadFileFS
23
+ fs.ReadDirFS
24
+}
25
+
26
+// Frontend handles serving embedded frontend assets and SSR.
27
+type Frontend struct {
28
+ distFS readDirFileFS
29
+ admin *Admin
30
+
31
+ cachedPortalHTML []byte
32
+ cachedPortalHTMLOnce sync.Once
33
+
34
+ wasmCache map[string]*wasmCacheEntry
35
+ wasmCacheMu sync.RWMutex
36
+}
37
+
38
+func NewFrontend() *Frontend {
39
+ return &Frontend{
40
+ distFS: distFS,
41
+ wasmCache: make(map[string]*wasmCacheEntry),
42
+ }
43
+}
44
+
45
+// SetAdmin attaches an Admin instance. Frontend methods tolerate nil admin.
46
+func (f *Frontend) SetAdmin(admin *Admin) {
47
+ f.admin = admin
48
+}
49
+
50
+func (f *Frontend) initPortalHTMLCache() error {
51
+ var err error
52
+ f.cachedPortalHTML, err = f.distFS.ReadFile("dist/app/portal.html")
53
+ return err
54
+}
55
+
56
+func (f *Frontend) ServeAsset(mux *http.ServeMux, route, assetPath, contentType string) {
57
+ mux.HandleFunc(route, func(w http.ResponseWriter, r *http.Request) {
58
+ fullPath := path.Join("dist", "app", assetPath)
59
+ b, err := f.distFS.ReadFile(fullPath)
60
+ if err != nil {
61
+ http.NotFound(w, r)
62
+ return
63
+ }
64
+ if contentType != "" {
65
+ w.Header().Set("Content-Type", contentType)
66
+ }
67
+ w.WriteHeader(http.StatusOK)
68
+ _, _ = w.Write(b)
69
+ })
70
+}
71
+
72
+// servePortalHTMLWithSSR serves portal.html with SSR data injection.
73
+func (f *Frontend) servePortalHTMLWithSSR(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer) {
74
+ utils.SetCORSHeaders(w)
75
+
76
+ // Initialize cache on first use
77
+ f.cachedPortalHTMLOnce.Do(func() {
78
+ if err := f.initPortalHTMLCache(); err != nil {
79
+ log.Error().Err(err).Msg("Failed to cache portal.html")
80
+ }
81
+ })
82
+
83
+ if f.cachedPortalHTML == nil {
84
+ http.NotFound(w, r)
85
+ return
86
+ }
87
+
88
+ // Inject SSR data into cached template
89
+ injectedHTML := f.injectServerData(string(f.cachedPortalHTML), serv)
90
+
91
+ // Set headers
92
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
93
+ w.Header().Set("Cache-Control", "no-cache, must-revalidate")
94
+
95
+ // Send response
96
+ w.WriteHeader(http.StatusOK)
97
+ w.Write([]byte(injectedHTML))
98
+
99
+ log.Debug().Msg("Served portal.html with SSR data")
100
+}
101
+
102
+// injectServerData injects server data into HTML for SSR
103
+func (f *Frontend) injectServerData(htmlContent string, serv *portal.RelayServer) string {
104
+ // Get server data from lease manager
105
+ rows := []leaseRow{}
106
+ if f.admin != nil {
107
+ rows = convertLeaseEntriesToRows(serv, f.admin)
108
+ }
109
+
110
+ // Marshal to JSON
111
+ jsonData, err := json.Marshal(rows)
112
+ if err != nil {
113
+ log.Error().Err(err).Msg("Failed to marshal server data for SSR")
114
+ jsonData = []byte("[]")
115
+ }
116
+
117
+ // Create SSR script tag
118
+ ssrScript := `<script id="__SSR_DATA__" type="application/json">` + string(jsonData) + `</script>`
119
+
120
+ // Inject before </head> tag
121
+ injected := strings.Replace(htmlContent, "</head>", ssrScript+"\n</head>", 1)
122
+
123
+ log.Debug().
124
+ Int("rows", len(rows)).
125
+ Int("jsonSize", len(jsonData)).
126
+ Msg("Injected SSR data into HTML")
127
+
128
+ return injected
129
+}
130
+
131
+// convertLeaseEntriesToRows converts LeaseEntry data from LeaseManager to leaseRow format for the app page.
132
+func convertLeaseEntriesToRows(serv *portal.RelayServer, admin *Admin) []leaseRow {
133
+ leaseEntries := serv.GetAllLeaseEntries()
134
+ rows := []leaseRow{}
135
+ now := time.Now()
136
+
137
+ bannedList := serv.GetLeaseManager().GetBannedLeases()
138
+ bannedMap := make(map[string]struct{}, len(bannedList))
139
+ for _, b := range bannedList {
140
+ bannedMap[string(b)] = struct{}{}
141
+ }
142
+
143
+ for _, leaseEntry := range leaseEntries {
144
+ if now.After(leaseEntry.Expires) {
145
+ continue
146
+ }
147
+
148
+ lease := leaseEntry.Lease
149
+ identityID := string(lease.Identity.Id)
150
+
151
+ var metadata sdk.Metadata
152
+ _ = json.Unmarshal([]byte(lease.Metadata), &metadata)
153
+
154
+ if _, banned := bannedMap[identityID]; banned {
155
+ continue
156
+ }
157
+
158
+ if admin != nil {
159
+ approveManager := admin.GetApproveManager()
160
+ if approveManager.GetApprovalMode() == manager.ApprovalModeManual && !approveManager.IsLeaseApproved(identityID) {
161
+ continue
162
+ }
163
+ }
164
+
165
+ if metadata.Hide {
166
+ continue
167
+ }
168
+
169
+ ttl := time.Until(leaseEntry.Expires)
170
+ ttlStr := ""
171
+ if ttl > 0 {
172
+ if ttl > time.Hour {
173
+ ttlStr = fmt.Sprintf("%.0fh", ttl.Hours())
174
+ } else if ttl > time.Minute {
175
+ ttlStr = fmt.Sprintf("%.0fm", ttl.Minutes())
176
+ } else {
177
+ ttlStr = fmt.Sprintf("%.0fs", ttl.Seconds())
178
+ }
179
+ }
180
+
181
+ since := now.Sub(leaseEntry.LastSeen)
182
+ if since < 0 {
183
+ since = 0
184
+ }
185
+ lastSeenStr := func(d time.Duration) string {
186
+ if d >= time.Hour {
187
+ h := int(d / time.Hour)
188
+ m := int((d % time.Hour) / time.Minute)
189
+ if m > 0 {
190
+ return fmt.Sprintf("%dh %dm", h, m)
191
+ }
192
+ return fmt.Sprintf("%dh", h)
193
+ }
194
+ if d >= time.Minute {
195
+ m := int(d / time.Minute)
196
+ s := int((d % time.Minute) / time.Second)
197
+ if s > 0 {
198
+ return fmt.Sprintf("%dm %ds", m, s)
199
+ }
200
+ return fmt.Sprintf("%dm", m)
201
+ }
202
+ return fmt.Sprintf("%ds", int(d/time.Second))
203
+ }(since)
204
+ lastSeenISO := leaseEntry.LastSeen.UTC().Format(time.RFC3339)
205
+ firstSeenISO := leaseEntry.FirstSeen.UTC().Format(time.RFC3339)
206
+
207
+ connected := serv.IsConnectionActive(leaseEntry.ConnectionID)
208
+
209
+ if !connected && since >= 3*time.Minute {
210
+ continue
211
+ }
212
+
213
+ name := lease.Name
214
+ if name == "" {
215
+ name = "(unnamed)"
216
+ }
217
+
218
+ kind := "client"
219
+ if len(lease.Alpn) > 0 {
220
+ kind = lease.Alpn[0]
221
+ }
222
+
223
+ dnsLabel := identityID
224
+ if len(dnsLabel) > 8 {
225
+ dnsLabel = dnsLabel[:8] + "..."
226
+ }
227
+
228
+ base := flagPortalAppURL
229
+ if base == "" {
230
+ base = flagPortalURL
231
+ }
232
+ link := fmt.Sprintf("//%s.%s/", lease.Name, utils.StripWildCard(utils.StripScheme(base)))
233
+
234
+ var bps int64
235
+ if bpsMgr := admin.GetBPSManager(); bpsMgr != nil {
236
+ bps = bpsMgr.GetBPSLimit(identityID)
237
+ }
238
+
239
+ row := leaseRow{
240
+ Peer: identityID,
241
+ Name: name,
242
+ Kind: kind,
243
+ Connected: connected,
244
+ DNS: dnsLabel,
245
+ LastSeen: lastSeenStr,
246
+ LastSeenISO: lastSeenISO,
247
+ FirstSeenISO: firstSeenISO,
248
+ TTL: ttlStr,
249
+ Link: link,
250
+ StaleRed: !connected && since >= 15*time.Second,
251
+ Hide: leaseEntry.ParsedMetadata != nil && leaseEntry.ParsedMetadata.Hide,
252
+ Metadata: lease.Metadata,
253
+ BPS: bps,
254
+ }
255
+
256
+ if !metadata.Hide {
257
+ rows = append(rows, row)
258
+ }
259
+ }
260
+
261
+ return rows
262
+}
263
+
264
+// ServePortalStaticFile serves static files for portal frontend with caching.
265
+func (f *Frontend) ServePortalStaticFile(w http.ResponseWriter, r *http.Request, filePath string) {
266
+ // Check if this is a content-addressed WASM file
267
+ if strings.HasSuffix(filePath, ".wasm") {
268
+ hash := strings.TrimSuffix(filePath, ".wasm")
269
+ if utils.IsHexString(hash) {
270
+ f.serveCompressedWasm(w, r, filePath)
271
+ return
272
+ }
273
+ }
274
+
275
+ // Regular static file serving
276
+ w.Header().Set("Cache-Control", "public, max-age=3600")
277
+ f.ServeStaticFile(w, r, filePath, "")
278
+}
279
+
280
+// ServeAppStatic serves static files for app UI (React app) from embedded FS.
281
+// Falls back to portal.html with SSR when path is root or file not found.
282
+func (f *Frontend) ServeAppStatic(w http.ResponseWriter, r *http.Request, appPath string, serv *portal.RelayServer) {
283
+ // Prevent directory traversal
284
+ if strings.Contains(appPath, "..") {
285
+ http.Error(w, "Invalid path", http.StatusBadRequest)
286
+ return
287
+ }
288
+
289
+ utils.SetCORSHeaders(w)
290
+
291
+ // If path is empty or "/", serve portal.html with SSR
292
+ if appPath == "" || appPath == "/" {
293
+ f.servePortalHTMLWithSSR(w, r, serv)
294
+ return
295
+ }
296
+
297
+ // Try to read from embedded FS
298
+ fullPath := path.Join("dist", "app", appPath)
299
+ data, err := f.distFS.ReadFile(fullPath)
300
+ if err != nil {
301
+ // File not found - fallback to portal.html with SSR for SPA routing
302
+ log.Debug().Err(err).Str("path", appPath).Msg("app static file not found, falling back to SSR")
303
+ f.servePortalHTMLWithSSR(w, r, serv)
304
+ return
305
+ }
306
+
307
+ // Set content type based on extension
308
+ ext := path.Ext(appPath)
309
+ contentType := utils.GetContentType(ext)
310
+ if contentType != "" {
311
+ w.Header().Set("Content-Type", contentType)
312
+ }
313
+
314
+ w.Header().Set("Cache-Control", "public, max-age=3600")
315
+ w.WriteHeader(http.StatusOK)
316
+ w.Write(data)
317
+
318
+ log.Debug().
319
+ Str("path", appPath).
320
+ Int("size", len(data)).
321
+ Msg("served app static file")
322
+}
323
+
324
+type wasmCacheEntry struct {
325
+ brotli []byte
326
+ hash string
327
+}
328
+
329
+// initWasmCache loads pre-built WASM artifacts (precompressed) into memory on startup.
330
+func (f *Frontend) InitWasmCache() error {
331
+ // Read all files in embedded dist/wasm directory
332
+ entries, err := f.distFS.ReadDir("dist/wasm")
333
+ if err != nil {
334
+ return err
335
+ }
336
+
337
+ for _, entry := range entries {
338
+ if entry.IsDir() {
339
+ continue
340
+ }
341
+
342
+ name := entry.Name()
343
+ // Look for content-addressed WASM files: <hex>.wasm.br
344
+ if strings.HasSuffix(name, ".wasm.br") {
345
+ hash := strings.TrimSuffix(name, ".wasm.br")
346
+ if utils.IsHexString(hash) {
347
+ fullPath := path.Join("dist", "wasm", name)
348
+ // Cache under the URL path (<hash>.wasm) while reading the
349
+ // brotli-compressed artifact (<hash>.wasm.br) from embed.FS.
350
+ cacheKey := hash + ".wasm"
351
+ if err := f.cacheWasmFile(cacheKey, fullPath); err != nil {
352
+ log.Warn().Err(err).Str("file", name).Msg("failed to cache WASM file")
353
+ } else {
354
+ log.Info().Str("file", cacheKey).Msg("cached WASM file")
355
+ }
356
+ }
357
+ }
358
+ }
359
+
360
+ return nil
361
+}
362
+
363
+// cacheWasmFile reads and caches a WASM file and its pre-compressed variant (brotli).
364
+func (f *Frontend) cacheWasmFile(name, fullPath string) error {
365
+ // Verify name looks like a hex hash (name is <hash>.wasm).
366
+ hashHex := strings.TrimSuffix(name, ".wasm")
367
+ if !utils.IsHexString(hashHex) {
368
+ log.Warn().Str("file", name).Msg("WASM file name is not a valid SHA256 hex string")
369
+ }
370
+
371
+ // Load precompressed variant (brotli) from embed.FS (<hash>.wasm.br)
372
+ var brData []byte
373
+ data, err := f.distFS.ReadFile(fullPath)
374
+ if err != nil {
375
+ log.Warn().Err(err).Str("file", fullPath).Msg("failed to read brotli-compressed WASM")
376
+ } else {
377
+ brData = data
378
+ }
379
+
380
+ entry := &wasmCacheEntry{
381
+ brotli: brData,
382
+ hash: hashHex,
383
+ }
384
+
385
+ f.wasmCacheMu.Lock()
386
+ f.wasmCache[name] = entry
387
+ f.wasmCacheMu.Unlock()
388
+
389
+ log.Debug().
390
+ Str("file", name).
391
+ Int("brotli", len(entry.brotli)).
392
+ Msg("WASM file cached")
393
+
394
+ return nil
395
+}
396
+
397
+// serveCompressedWasm serves pre-compressed WASM files from memory cache
398
+func (f *Frontend) serveCompressedWasm(w http.ResponseWriter, r *http.Request, filePath string) {
399
+ f.wasmCacheMu.RLock()
400
+ entry, ok := f.wasmCache[filePath]
401
+ f.wasmCacheMu.RUnlock()
402
+
403
+ if !ok {
404
+ log.Debug().Str("path", filePath).Msg("WASM file not in cache")
405
+ // Fallback: try to serve uncompressed WASM from embedded FS
406
+ fullPath := path.Join("dist", "wasm", filePath)
407
+ data, err := f.distFS.ReadFile(fullPath)
408
+ if err != nil {
409
+ log.Debug().Err(err).Str("path", fullPath).Msg("WASM file not found in embedded FS")
410
+ http.NotFound(w, r)
411
+ return
412
+ }
413
+
414
+ // Serve uncompressed WASM
415
+ utils.SetCORSHeaders(w)
416
+ w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
417
+ w.Header().Set("Content-Type", "application/wasm")
418
+ w.Header().Set("Content-Length", strconv.Itoa(len(data)))
419
+ w.WriteHeader(http.StatusOK)
420
+ w.Write(data)
421
+ log.Debug().
422
+ Str("path", filePath).
423
+ Int("size", len(data)).
424
+ Msg("served uncompressed WASM from embedded FS")
425
+ return
426
+ }
427
+
428
+ // Set immutable cache headers for content-addressed files
429
+ utils.SetCORSHeaders(w)
430
+ w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
431
+ w.Header().Set("Content-Type", "application/wasm")
432
+
433
+ // Check Accept-Encoding header for brotli support
434
+ acceptEncoding := r.Header.Get("Accept-Encoding")
435
+
436
+ // Require brotli-compressed WASM
437
+ if !strings.Contains(acceptEncoding, "br") || len(entry.brotli) == 0 {
438
+ log.Warn().
439
+ Str("path", filePath).
440
+ Str("acceptEncoding", acceptEncoding).
441
+ Msg("client does not support brotli or brotli variant missing for WASM")
442
+ http.Error(w, "brotli-compressed WASM required", http.StatusNotAcceptable)
443
+ return
444
+ }
445
+
446
+ w.Header().Set("Content-Encoding", "br")
447
+ w.Header().Set("Content-Length", strconv.Itoa(len(entry.brotli)))
448
+ w.WriteHeader(http.StatusOK)
449
+ w.Write(entry.brotli)
450
+ log.Debug().
451
+ Str("path", filePath).
452
+ Int("size", len(entry.brotli)).
453
+ Str("encoding", "brotli").
454
+ Msg("served compressed WASM")
455
+}
456
+
457
+// ServePortalStatic serves static files for portal frontend with appropriate cache headers.
458
+// Falls back to portal.html for SPA routing (404 -> portal.html).
459
+func (f *Frontend) ServePortalStatic(w http.ResponseWriter, r *http.Request) {
460
+ staticPath := strings.TrimPrefix(r.URL.Path, "/")
461
+
462
+ // Prevent directory traversal
463
+ if strings.Contains(staticPath, "..") {
464
+ http.Error(w, "Invalid path", http.StatusBadRequest)
465
+ return
466
+ }
467
+
468
+ // Special handling for specific files
469
+ switch staticPath {
470
+ case "manifest.json":
471
+ // Serve dynamic manifest regardless of static presence
472
+ f.ServeDynamicManifest(w, r)
473
+ return
474
+
475
+ case "service-worker.js":
476
+ f.ServeDynamicServiceWorker(w, r)
477
+ return
478
+
479
+ case "wasm_exec.js":
480
+ w.Header().Set("Cache-Control", "public, max-age=86400")
481
+ w.Header().Set("Content-Type", "application/javascript")
482
+ f.serveStaticFileWithFallback(w, r, staticPath, "application/javascript")
483
+ return
484
+
485
+ case "portal.mp4":
486
+ w.Header().Set("Cache-Control", "public, max-age=604800")
487
+ w.Header().Set("Content-Type", "video/mp4")
488
+ f.serveStaticFileWithFallback(w, r, staticPath, "video/mp4")
489
+ return
490
+ }
491
+
492
+ // Default caching for other files
493
+ w.Header().Set("Cache-Control", "public, max-age=3600")
494
+ f.serveStaticFileWithFallback(w, r, staticPath, "")
495
+}
496
+
497
+// ServeStaticFile reads and serves a file from the static directory.
498
+func (f *Frontend) ServeStaticFile(w http.ResponseWriter, r *http.Request, filePath string, contentType string) {
499
+ utils.SetCORSHeaders(w)
500
+
501
+ fullPath := path.Join("dist", "wasm", filePath)
502
+ data, err := f.distFS.ReadFile(fullPath)
503
+ if err != nil {
504
+ log.Debug().Err(err).Str("path", filePath).Msg("static file not found")
505
+ http.NotFound(w, r)
506
+ return
507
+ }
508
+
509
+ // Set content type
510
+ if contentType != "" {
511
+ w.Header().Set("Content-Type", contentType)
512
+ } else {
513
+ ext := path.Ext(filePath)
514
+ ct := utils.GetContentType(ext)
515
+ if ct != "" {
516
+ w.Header().Set("Content-Type", ct)
517
+ }
518
+ }
519
+
520
+ log.Debug().
521
+ Str("path", filePath).
522
+ Int("size", len(data)).
523
+ Msg("served static file")
524
+
525
+ w.WriteHeader(http.StatusOK)
526
+ w.Write(data)
527
+}
528
+
529
+// serveStaticFileWithFallback reads and serves a file from the static directory
530
+// If the file is not found, it falls back to portal.html for SPA routing
531
+func (f *Frontend) serveStaticFileWithFallback(w http.ResponseWriter, r *http.Request, filePath string, contentType string) {
532
+ utils.SetCORSHeaders(w)
533
+
534
+ fullPath := path.Join("dist", "wasm", filePath)
535
+ data, err := f.distFS.ReadFile(fullPath)
536
+ if err != nil {
537
+ // File not found - fallback to portal.html for SPA routing
538
+ log.Debug().Err(err).Str("path", filePath).Msg("static file not found, serving portal.html")
539
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
540
+ f.ServeStaticFile(w, r, "portal.html", "text/html; charset=utf-8")
541
+ return
542
+ }
543
+
544
+ // Set content type
545
+ if contentType != "" {
546
+ w.Header().Set("Content-Type", contentType)
547
+ } else {
548
+ ext := path.Ext(filePath)
549
+ ct := utils.GetContentType(ext)
550
+ if ct != "" {
551
+ w.Header().Set("Content-Type", ct)
552
+ }
553
+ }
554
+
555
+ log.Debug().
556
+ Str("path", filePath).
557
+ Int("size", len(data)).
558
+ Msg("served static file")
559
+
560
+ w.WriteHeader(http.StatusOK)
561
+ w.Write(data)
562
+}
563
+
564
+// serveDynamicManifest generates and serves manifest.json dynamically
565
+func (f *Frontend) ServeDynamicManifest(w http.ResponseWriter, _ *http.Request) {
566
+ utils.SetCORSHeaders(w)
567
+
568
+ // Find the content-addressed WASM file
569
+ f.wasmCacheMu.RLock()
570
+ var wasmHash string
571
+ var wasmFile string
572
+ for filename, entry := range f.wasmCache {
573
+ wasmHash = entry.hash
574
+ wasmFile = filename
575
+ break // Use the first (and should be only) WASM file
576
+ }
577
+ f.wasmCacheMu.RUnlock()
578
+
579
+ // Fallback: scan embedded WASM directory if cache is empty
580
+ if wasmHash == "" {
581
+ entries, err := f.distFS.ReadDir("dist/wasm")
582
+ if err == nil {
583
+ for _, entry := range entries {
584
+ if entry.IsDir() {
585
+ continue
586
+ }
587
+ name := entry.Name()
588
+ // Look for content-addressed WASM files: <hex>.wasm.br
589
+ if strings.HasSuffix(name, ".wasm.br") {
590
+ hash := strings.TrimSuffix(name, ".wasm.br")
591
+ if utils.IsHexString(hash) {
592
+ wasmHash = hash
593
+ wasmFile = hash + ".wasm"
594
+ break
595
+ }
596
+ }
597
+ }
598
+ }
599
+ }
600
+
601
+ // Generate WASM URL
602
+ wasmURL := flagPortalURL + "/frontend/" + wasmFile
603
+
604
+ // Create manifest structure
605
+ manifest := map[string]string{
606
+ "wasmFile": wasmFile,
607
+ "wasmUrl": wasmURL,
608
+ "hash": wasmHash,
609
+ "bootstraps": strings.Join(flagBootstraps, ","),
610
+ }
611
+
612
+ // Set headers for no caching
613
+ w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
614
+ w.Header().Set("Pragma", "no-cache")
615
+ w.Header().Set("Expires", "0")
616
+ w.Header().Set("Content-Type", "application/json")
617
+
618
+ // Encode and send
619
+ w.WriteHeader(http.StatusOK)
620
+ if err := json.NewEncoder(w).Encode(manifest); err != nil {
621
+ log.Error().Err(err).Msg("Failed to encode manifest")
622
+ }
623
+
624
+ log.Debug().
625
+ Str("wasmFile", wasmFile).
626
+ Str("wasmUrl", wasmURL).
627
+ Str("hash", wasmHash).
628
+ Str("bootstraps", strings.Join(flagBootstraps, ",")).
629
+ Msg("Served dynamic manifest")
630
+}
631
+
632
+// ServeDynamicServiceWorker serves service-worker.js with injected manifest and config.
633
+func (f *Frontend) ServeDynamicServiceWorker(w http.ResponseWriter, r *http.Request) {
634
+ utils.SetCORSHeaders(w)
635
+
636
+ // Read the service-worker.js template
637
+ fullPath := path.Join("dist", "wasm", "service-worker.js")
638
+ content, err := f.distFS.ReadFile(fullPath)
639
+ if err != nil {
640
+ log.Error().Err(err).Msg("Failed to read service-worker.js")
641
+ http.NotFound(w, r)
642
+ return
643
+ }
644
+
645
+ // Set headers for no caching
646
+ w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
647
+ w.Header().Set("Pragma", "no-cache")
648
+ w.Header().Set("Expires", "0")
649
+ w.Header().Set("Content-Type", "application/javascript")
650
+
651
+ // Send response
652
+ w.WriteHeader(http.StatusOK)
653
+ w.Write(content)
654
+
655
+ log.Debug().Msg("Served service-worker.js")
656
+}
cmd/relay-server/serve.go
+162
-602
@@ -1,656 +1,216 @@
1
package main
2
3
import (
4
- "encoding/json"
5
- "fmt"
6
- "io/fs"
4
+ "context"
5
+ "embed"
6
"net/http"
8
- "path"
9
- "strconv"
7
"strings"
11
- "sync"
12
- "time"
8
9
"github.com/rs/zerolog/log"
10
+
11
"gosuda.org/portal/cmd/relay-server/manager"
12
"gosuda.org/portal/portal"
17
- "gosuda.org/portal/sdk"
13
"gosuda.org/portal/utils"
14
)
15
21
-type readDirFileFS interface {
22
- fs.ReadFileFS
23
- fs.ReadDirFS
24
-}
25
-
26
-// Frontend handles serving embedded frontend assets and SSR.
27
-type Frontend struct {
28
- distFS readDirFileFS
29
- admin *Admin
30
-
31
- cachedPortalHTML []byte
32
- cachedPortalHTMLOnce sync.Once
33
-
34
- wasmCache map[string]*wasmCacheEntry
35
- wasmCacheMu sync.RWMutex
36
-}
37
-
38
-func NewFrontend() *Frontend {
39
- return &Frontend{
40
- distFS: distFS,
41
- wasmCache: make(map[string]*wasmCacheEntry),
42
- }
43
-}
44
-
45
-// SetAdmin attaches an Admin instance. Frontend methods tolerate nil admin.
46
-func (f *Frontend) SetAdmin(admin *Admin) {
47
- f.admin = admin
48
-}
49
-
50
-func (f *Frontend) initPortalHTMLCache() error {
51
- var err error
52
- f.cachedPortalHTML, err = f.distFS.ReadFile("dist/app/portal.html")
53
- return err
54
-}
55
-
56
-func (f *Frontend) ServeAsset(mux *http.ServeMux, route, assetPath, contentType string) {
57
- mux.HandleFunc(route, func(w http.ResponseWriter, r *http.Request) {
58
- fullPath := path.Join("dist", "app", assetPath)
59
- b, err := f.distFS.ReadFile(fullPath)
60
- if err != nil {
61
- http.NotFound(w, r)
62
- return
63
- }
64
- if contentType != "" {
65
- w.Header().Set("Content-Type", contentType)
66
- }
67
- w.WriteHeader(http.StatusOK)
68
- _, _ = w.Write(b)
69
- })
70
-}
71
-
72
-// servePortalHTMLWithSSR serves portal.html with SSR data injection.
73
-func (f *Frontend) servePortalHTMLWithSSR(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer) {
74
- utils.SetCORSHeaders(w)
75
-
76
- // Initialize cache on first use
77
- f.cachedPortalHTMLOnce.Do(func() {
78
- if err := f.initPortalHTMLCache(); err != nil {
79
- log.Error().Err(err).Msg("Failed to cache portal.html")
80
- }
81
- })
82
-
83
- if f.cachedPortalHTML == nil {
84
- http.NotFound(w, r)
85
- return
86
- }
87
-
88
- // Inject SSR data into cached template
89
- injectedHTML := f.injectServerData(string(f.cachedPortalHTML), serv)
90
-
91
- // Set headers
92
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
93
- w.Header().Set("Cache-Control", "no-cache, must-revalidate")
94
-
95
- // Send response
96
- w.WriteHeader(http.StatusOK)
97
- w.Write([]byte(injectedHTML))
98
-
99
- log.Debug().Msg("Served portal.html with SSR data")
100
-}
16
+//go:embed dist/*
17
+var distFS embed.FS
18
102
-// injectServerData injects server data into HTML for SSR
103
-func (f *Frontend) injectServerData(htmlContent string, serv *portal.RelayServer) string {
104
- // Get server data from lease manager
105
- rows := []leaseRow{}
106
- if f.admin != nil {
107
- rows = convertLeaseEntriesToRows(serv, f.admin)
19
+// serveHTTP builds the HTTP mux and returns the server.
20
+func serveHTTP(addr string, serv *portal.RelayServer, admin *Admin, frontend *Frontend, noIndex bool, cancel context.CancelFunc) *http.Server {
21
+ if addr == "" {
22
+ addr = ":0"
23
}
24
110
- // Marshal to JSON
111
- jsonData, err := json.Marshal(rows)
112
- if err != nil {
113
- log.Error().Err(err).Msg("Failed to marshal server data for SSR")
114
- jsonData = []byte("[]")
25
+ // Initialize WASM cache used by content handlers
26
+ if err := frontend.InitWasmCache(); err != nil {
27
+ log.Error().Err(err).Msg("failed to initialize WASM cache")
28
}
29
117
- // Create SSR script tag
118
- ssrScript := `<script id="__SSR_DATA__" type="application/json">` + string(jsonData) + `</script>`
30
+ // Create app UI mux
31
+ appMux := http.NewServeMux()
32
120
- // Inject before </head> tag
121
- injected := strings.Replace(htmlContent, "</head>", ssrScript+"\n</head>", 1)
33
+ // Serve favicons (ico/png/svg) from dist/app
34
+ frontend.ServeAsset(appMux, "/favicon.ico", "favicon.ico", "image/x-icon")
35
+ frontend.ServeAsset(appMux, "/favicon.png", "favicon.png", "image/png")
36
+ frontend.ServeAsset(appMux, "/favicon.svg", "favicon.svg", "image/svg+xml")
37
123
- log.Debug().
124
- Int("rows", len(rows)).
125
- Int("jsonSize", len(jsonData)).
126
- Msg("Injected SSR data into HTML")
127
-
128
- return injected
129
-}
130
-
131
-// convertLeaseEntriesToRows converts LeaseEntry data from LeaseManager to leaseRow format for the app page.
132
-func convertLeaseEntriesToRows(serv *portal.RelayServer, admin *Admin) []leaseRow {
133
- leaseEntries := serv.GetAllLeaseEntries()
134
- rows := []leaseRow{}
135
- now := time.Now()
136
-
137
- bannedList := serv.GetLeaseManager().GetBannedLeases()
138
- bannedMap := make(map[string]struct{}, len(bannedList))
139
- for _, b := range bannedList {
140
- bannedMap[string(b)] = struct{}{}
38
+ if noIndex {
39
+ appMux.HandleFunc("/robots.txt", func(w http.ResponseWriter, r *http.Request) {
40
+ w.Header().Set("Content-Type", "text/plain")
41
+ w.WriteHeader(http.StatusOK)
42
+ w.Write([]byte("User-agent: *\nDisallow: /\n"))
43
+ })
44
}
45
143
- for _, leaseEntry := range leaseEntries {
144
- if now.After(leaseEntry.Expires) {
145
- continue
146
- }
147
-
148
- lease := leaseEntry.Lease
149
- identityID := string(lease.Identity.Id)
150
-
151
- var metadata sdk.Metadata
152
- _ = json.Unmarshal([]byte(lease.Metadata), &metadata)
153
-
154
- if _, banned := bannedMap[identityID]; banned {
155
- continue
156
- }
157
-
158
- if admin != nil {
159
- approveManager := admin.GetApproveManager()
160
- if approveManager.GetApprovalMode() == manager.ApprovalModeManual && !approveManager.IsLeaseApproved(identityID) {
161
- continue
162
- }
163
- }
164
-
165
- if metadata.Hide {
166
- continue
167
- }
168
-
169
- ttl := time.Until(leaseEntry.Expires)
170
- ttlStr := ""
171
- if ttl > 0 {
172
- if ttl > time.Hour {
173
- ttlStr = fmt.Sprintf("%.0fh", ttl.Hours())
174
- } else if ttl > time.Minute {
175
- ttlStr = fmt.Sprintf("%.0fm", ttl.Minutes())
176
- } else {
177
- ttlStr = fmt.Sprintf("%.0fs", ttl.Seconds())
178
- }
179
- }
180
-
181
- since := now.Sub(leaseEntry.LastSeen)
182
- if since < 0 {
183
- since = 0
46
+ // Portal app assets (JS, CSS, etc.) - served from /app/
47
+ appMux.HandleFunc("/app/", func(w http.ResponseWriter, r *http.Request) {
48
+ utils.SetCORSHeaders(w)
49
+ if r.Method == http.MethodOptions {
50
+ w.WriteHeader(http.StatusOK)
51
+ return
52
}
185
- lastSeenStr := func(d time.Duration) string {
186
- if d >= time.Hour {
187
- h := int(d / time.Hour)
188
- m := int((d % time.Hour) / time.Minute)
189
- if m > 0 {
190
- return fmt.Sprintf("%dh %dm", h, m)
191
- }
192
- return fmt.Sprintf("%dh", h)
193
- }
194
- if d >= time.Minute {
195
- m := int(d / time.Minute)
196
- s := int((d % time.Minute) / time.Second)
197
- if s > 0 {
198
- return fmt.Sprintf("%dm %ds", m, s)
199
- }
200
- return fmt.Sprintf("%dm", m)
201
- }
202
- return fmt.Sprintf("%ds", int(d/time.Second))
203
- }(since)
204
- lastSeenISO := leaseEntry.LastSeen.UTC().Format(time.RFC3339)
205
- firstSeenISO := leaseEntry.FirstSeen.UTC().Format(time.RFC3339)
206
-
207
- connected := serv.IsConnectionActive(leaseEntry.ConnectionID)
53
+ p := strings.TrimPrefix(r.URL.Path, "/app/")
54
+ frontend.ServeAppStatic(w, r, p, serv)
55
+ })
56
209
- if !connected && since >= 3*time.Minute {
210
- continue
57
+ // Portal frontend files (for unified caching)
58
+ appMux.HandleFunc("/frontend/", func(w http.ResponseWriter, r *http.Request) {
59
+ utils.SetCORSHeaders(w)
60
+ if r.Method == http.MethodOptions {
61
+ w.WriteHeader(http.StatusOK)
62
+ return
63
}
212
-
213
- name := lease.Name
214
- if name == "" {
215
- name = "(unnamed)"
64
+ p := strings.TrimPrefix(r.URL.Path, "/frontend/")
65
+ if p == "manifest.json" {
66
+ frontend.ServeDynamicManifest(w, r)
67
+ return
68
}
69
218
- kind := "client"
219
- if len(lease.Alpn) > 0 {
220
- kind = lease.Alpn[0]
221
- }
70
+ frontend.ServePortalStaticFile(w, r, p)
71
+ })
72
223
- dnsLabel := identityID
224
- if len(dnsLabel) > 8 {
225
- dnsLabel = dnsLabel[:8] + "..."
226
- }
73
+ // Tunnel installer script and binaries
74
+ appMux.HandleFunc("/tunnel", func(w http.ResponseWriter, r *http.Request) {
75
+ serveTunnelScript(w, r)
76
+ })
77
+ appMux.HandleFunc("/tunnel/bin/", func(w http.ResponseWriter, r *http.Request) {
78
+ serveTunnelBinary(w, r)
79
+ })
80
228
- base := flagPortalAppURL
229
- if base == "" {
230
- base = flagPortalURL
81
+ appMux.HandleFunc("/relay", func(w http.ResponseWriter, r *http.Request) {
82
+ if r.Method != http.MethodGet {
83
+ w.Header().Set("Allow", http.MethodGet)
84
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
85
+ return
86
}
232
- link := fmt.Sprintf("//%s.%s/", lease.Name, utils.StripWildCard(utils.StripScheme(base)))
87
234
- var bps int64
235
- if bpsMgr := admin.GetBPSManager(); bpsMgr != nil {
236
- bps = bpsMgr.GetBPSLimit(identityID)
88
+ // Check if IP is banned
89
+ clientIP := manager.ExtractClientIP(r)
90
+ ipManager := admin.GetIPManager()
91
+ if ipManager != nil && ipManager.IsIPBanned(clientIP) {
92
+ log.Warn().Str("ip", clientIP).Msg("[server] connection rejected: IP banned")
93
+ http.Error(w, "forbidden", http.StatusForbidden)
94
+ return
95
}
96
239
- row := leaseRow{
240
- Peer: identityID,
241
- Name: name,
242
- Kind: kind,
243
- Connected: connected,
244
- DNS: dnsLabel,
245
- LastSeen: lastSeenStr,
246
- LastSeenISO: lastSeenISO,
247
- FirstSeenISO: firstSeenISO,
248
- TTL: ttlStr,
249
- Link: link,
250
- StaleRed: !connected && since >= 15*time.Second,
251
- Hide: leaseEntry.ParsedMetadata != nil && leaseEntry.ParsedMetadata.Hide,
252
- Metadata: lease.Metadata,
253
- BPS: bps,
97
+ stream, wsConn, err := utils.UpgradeToWSStream(w, r, nil)
98
+ if err != nil {
99
+ log.Error().Err(err).Msg("[server] websocket upgrade failed")
100
+ return
101
}
102
256
- if !metadata.Hide {
257
- rows = append(rows, row)
103
+ // Store pending IP for lease association (will be linked when lease is registered)
104
+ if ipManager != nil && clientIP != "" {
105
+ ipManager.StorePendingIP(clientIP)
106
}
259
- }
260
-
261
- return rows
262
-}
107
264
-// ServePortalStaticFile serves static files for portal frontend with caching.
265
-func (f *Frontend) ServePortalStaticFile(w http.ResponseWriter, r *http.Request, filePath string) {
266
- // Check if this is a content-addressed WASM file
267
- if strings.HasSuffix(filePath, ".wasm") {
268
- hash := strings.TrimSuffix(filePath, ".wasm")
269
- if utils.IsHexString(hash) {
270
- f.serveCompressedWasm(w, r, filePath)
108
+ if err := serv.HandleConnection(stream); err != nil {
109
+ log.Error().Err(err).Msg("[server] websocket relay connection error")
110
+ wsConn.Close()
111
return
112
}
273
- }
274
-
275
- // Regular static file serving
276
- w.Header().Set("Cache-Control", "public, max-age=3600")
277
- f.ServeStaticFile(w, r, filePath, "")
278
-}
279
-
280
-// ServeAppStatic serves static files for app UI (React app) from embedded FS.
281
-// Falls back to portal.html with SSR when path is root or file not found.
282
-func (f *Frontend) ServeAppStatic(w http.ResponseWriter, r *http.Request, appPath string, serv *portal.RelayServer) {
283
- // Prevent directory traversal
284
- if strings.Contains(appPath, "..") {
285
- http.Error(w, "Invalid path", http.StatusBadRequest)
286
- return
287
- }
288
-
289
- utils.SetCORSHeaders(w)
290
-
291
- // If path is empty or "/", serve portal.html with SSR
292
- if appPath == "" || appPath == "/" {
293
- f.servePortalHTMLWithSSR(w, r, serv)
294
- return
295
- }
296
-
297
- // Try to read from embedded FS
298
- fullPath := path.Join("dist", "app", appPath)
299
- data, err := f.distFS.ReadFile(fullPath)
300
- if err != nil {
301
- // File not found - fallback to portal.html with SSR for SPA routing
302
- log.Debug().Err(err).Str("path", appPath).Msg("app static file not found, falling back to SSR")
303
- f.servePortalHTMLWithSSR(w, r, serv)
304
- return
305
- }
306
-
307
- // Set content type based on extension
308
- ext := path.Ext(appPath)
309
- contentType := utils.GetContentType(ext)
310
- if contentType != "" {
311
- w.Header().Set("Content-Type", contentType)
312
- }
313
-
314
- w.Header().Set("Cache-Control", "public, max-age=3600")
315
- w.WriteHeader(http.StatusOK)
316
- w.Write(data)
113
+ })
114
318
- log.Debug().
319
- Str("path", appPath).
320
- Int("size", len(data)).
321
- Msg("served app static file")
322
-}
115
+ // App UI index page - serve React frontend with SSR (delegates to serveAppStatic)
116
+ appMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
117
+ // serveAppStatic handles both "/" and 404 fallback with SSR
118
+ p := strings.TrimPrefix(r.URL.Path, "/")
119
+ frontend.ServeAppStatic(w, r, p, serv)
120
+ })
121
324
-type wasmCacheEntry struct {
325
- brotli []byte
326
- hash string
327
-}
122
+ appMux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
123
+ w.WriteHeader(http.StatusOK)
124
+ w.Write([]byte("{\"status\":\"ok\"}"))
125
+ })
126
329
-// initWasmCache loads pre-built WASM artifacts (precompressed) into memory on startup.
330
-func (f *Frontend) InitWasmCache() error {
331
- // Read all files in embedded dist/wasm directory
332
- entries, err := f.distFS.ReadDir("dist/wasm")
333
- if err != nil {
334
- return err
335
- }
127
+ // Admin API
128
+ appMux.HandleFunc("/admin/", func(w http.ResponseWriter, r *http.Request) {
129
+ admin.HandleAdminRequest(w, r, serv)
130
+ })
131
337
- for _, entry := range entries {
338
- if entry.IsDir() {
339
- continue
340
- }
132
+ // Create portal frontend mux (routes only)
133
+ portalMux := http.NewServeMux()
134
342
- name := entry.Name()
343
- // Look for content-addressed WASM files: <hex>.wasm.br
344
- if strings.HasSuffix(name, ".wasm.br") {
345
- hash := strings.TrimSuffix(name, ".wasm.br")
346
- if utils.IsHexString(hash) {
347
- fullPath := path.Join("dist", "wasm", name)
348
- // Cache under the URL path (<hash>.wasm) while reading the
349
- // brotli-compressed artifact (<hash>.wasm.br) from embed.FS.
350
- cacheKey := hash + ".wasm"
351
- if err := f.cacheWasmFile(cacheKey, fullPath); err != nil {
352
- log.Warn().Err(err).Str("file", name).Msg("failed to cache WASM file")
353
- } else {
354
- log.Info().Str("file", cacheKey).Msg("cached WASM file")
355
- }
356
- }
135
+ // Static file handler for /frontend/ (for unified caching)
136
+ portalMux.HandleFunc("/frontend/", func(w http.ResponseWriter, r *http.Request) {
137
+ utils.SetCORSHeaders(w)
138
+ if r.Method == http.MethodOptions {
139
+ w.WriteHeader(http.StatusOK)
140
+ return
141
}
358
- }
359
-
360
- return nil
361
-}
362
-
363
-// cacheWasmFile reads and caches a WASM file and its pre-compressed variant (brotli).
364
-func (f *Frontend) cacheWasmFile(name, fullPath string) error {
365
- // Verify name looks like a hex hash (name is <hash>.wasm).
366
- hashHex := strings.TrimSuffix(name, ".wasm")
367
- if !utils.IsHexString(hashHex) {
368
- log.Warn().Str("file", name).Msg("WASM file name is not a valid SHA256 hex string")
369
- }
370
-
371
- // Load precompressed variant (brotli) from embed.FS (<hash>.wasm.br)
372
- var brData []byte
373
- data, err := f.distFS.ReadFile(fullPath)
374
- if err != nil {
375
- log.Warn().Err(err).Str("file", fullPath).Msg("failed to read brotli-compressed WASM")
376
- } else {
377
- brData = data
378
- }
379
-
380
- entry := &wasmCacheEntry{
381
- brotli: brData,
382
- hash: hashHex,
383
- }
384
-
385
- f.wasmCacheMu.Lock()
386
- f.wasmCache[name] = entry
387
- f.wasmCacheMu.Unlock()
388
-
389
- log.Debug().
390
- Str("file", name).
391
- Int("brotli", len(entry.brotli)).
392
- Msg("WASM file cached")
393
-
394
- return nil
395
-}
396
-
397
-// serveCompressedWasm serves pre-compressed WASM files from memory cache
398
-func (f *Frontend) serveCompressedWasm(w http.ResponseWriter, r *http.Request, filePath string) {
399
- f.wasmCacheMu.RLock()
400
- entry, ok := f.wasmCache[filePath]
401
- f.wasmCacheMu.RUnlock()
402
-
403
- if !ok {
404
- log.Debug().Str("path", filePath).Msg("WASM file not in cache")
405
- // Fallback: try to serve uncompressed WASM from embedded FS
406
- fullPath := path.Join("dist", "wasm", filePath)
407
- data, err := f.distFS.ReadFile(fullPath)
408
- if err != nil {
409
- log.Debug().Err(err).Str("path", fullPath).Msg("WASM file not found in embedded FS")
410
- http.NotFound(w, r)
142
+ p := strings.TrimPrefix(r.URL.Path, "/frontend/")
143
+ if p == "manifest.json" {
144
+ frontend.ServeDynamicManifest(w, r)
145
return
146
}
147
+ frontend.ServePortalStaticFile(w, r, p)
148
+ })
149
414
- // Serve uncompressed WASM
415
- utils.SetCORSHeaders(w)
416
- w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
417
- w.Header().Set("Content-Type", "application/wasm")
418
- w.Header().Set("Content-Length", strconv.Itoa(len(data)))
419
- w.WriteHeader(http.StatusOK)
420
- w.Write(data)
421
- log.Debug().
422
- Str("path", filePath).
423
- Int("size", len(data)).
424
- Msg("served uncompressed WASM from embedded FS")
425
- return
426
- }
427
-
428
- // Set immutable cache headers for content-addressed files
429
- utils.SetCORSHeaders(w)
430
- w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
431
- w.Header().Set("Content-Type", "application/wasm")
432
-
433
- // Check Accept-Encoding header for brotli support
434
- acceptEncoding := r.Header.Get("Accept-Encoding")
435
-
436
- // Require brotli-compressed WASM
437
- if !strings.Contains(acceptEncoding, "br") || len(entry.brotli) == 0 {
438
- log.Warn().
439
- Str("path", filePath).
440
- Str("acceptEncoding", acceptEncoding).
441
- Msg("client does not support brotli or brotli variant missing for WASM")
442
- http.Error(w, "brotli-compressed WASM required", http.StatusNotAcceptable)
443
- return
444
- }
445
-
446
- w.Header().Set("Content-Encoding", "br")
447
- w.Header().Set("Content-Length", strconv.Itoa(len(entry.brotli)))
448
- w.WriteHeader(http.StatusOK)
449
- w.Write(entry.brotli)
450
- log.Debug().
451
- Str("path", filePath).
452
- Int("size", len(entry.brotli)).
453
- Str("encoding", "brotli").
454
- Msg("served compressed WASM")
455
-}
456
-
457
-// ServePortalStatic serves static files for portal frontend with appropriate cache headers.
458
-// Falls back to portal.html for SPA routing (404 -> portal.html).
459
-func (f *Frontend) ServePortalStatic(w http.ResponseWriter, r *http.Request) {
460
- staticPath := strings.TrimPrefix(r.URL.Path, "/")
461
-
462
- // Prevent directory traversal
463
- if strings.Contains(staticPath, "..") {
464
- http.Error(w, "Invalid path", http.StatusBadRequest)
465
- return
466
- }
467
-
468
- // Special handling for specific files
469
- switch staticPath {
470
- case "manifest.json":
471
- // Serve dynamic manifest regardless of static presence
472
- f.ServeDynamicManifest(w, r)
473
- return
474
-
475
- case "service-worker.js":
476
- f.ServeDynamicServiceWorker(w, r)
477
- return
478
-
479
- case "wasm_exec.js":
480
- w.Header().Set("Cache-Control", "public, max-age=86400")
481
- w.Header().Set("Content-Type", "application/javascript")
482
- f.serveStaticFileWithFallback(w, r, staticPath, "application/javascript")
483
- return
484
-
485
- case "portal.mp4":
486
- w.Header().Set("Cache-Control", "public, max-age=604800")
487
- w.Header().Set("Content-Type", "video/mp4")
488
- f.serveStaticFileWithFallback(w, r, staticPath, "video/mp4")
489
- return
490
- }
491
-
492
- // Default caching for other files
493
- w.Header().Set("Cache-Control", "public, max-age=3600")
494
- f.serveStaticFileWithFallback(w, r, staticPath, "")
495
-}
496
-
497
-// ServeStaticFile reads and serves a file from the static directory.
498
-func (f *Frontend) ServeStaticFile(w http.ResponseWriter, r *http.Request, filePath string, contentType string) {
499
- utils.SetCORSHeaders(w)
500
-
501
- fullPath := path.Join("dist", "wasm", filePath)
502
- data, err := f.distFS.ReadFile(fullPath)
503
- if err != nil {
504
- log.Debug().Err(err).Str("path", filePath).Msg("static file not found")
505
- http.NotFound(w, r)
506
- return
507
- }
150
+ // Service worker for portal subdomains (serve from dist/wasm)
151
+ portalMux.HandleFunc("/service-worker.js", func(w http.ResponseWriter, r *http.Request) {
152
+ frontend.ServeDynamicServiceWorker(w, r)
153
+ })
154
509
- // Set content type
510
- if contentType != "" {
511
- w.Header().Set("Content-Type", contentType)
512
- } else {
513
- ext := path.Ext(filePath)
514
- ct := utils.GetContentType(ext)
515
- if ct != "" {
516
- w.Header().Set("Content-Type", ct)
155
+ // Root and SPA fallback for portal subdomains
156
+ portalMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
157
+ utils.SetCORSHeaders(w)
158
+ if r.Method == http.MethodOptions {
159
+ w.WriteHeader(http.StatusOK)
160
+ return
161
}
518
- }
519
-
520
- log.Debug().
521
- Str("path", filePath).
522
- Int("size", len(data)).
523
- Msg("served static file")
524
-
525
- w.WriteHeader(http.StatusOK)
526
- w.Write(data)
527
-}
528
-
529
-// serveStaticFileWithFallback reads and serves a file from the static directory
530
-// If the file is not found, it falls back to portal.html for SPA routing
531
-func (f *Frontend) serveStaticFileWithFallback(w http.ResponseWriter, r *http.Request, filePath string, contentType string) {
532
- utils.SetCORSHeaders(w)
533
-
534
- fullPath := path.Join("dist", "wasm", filePath)
535
- data, err := f.distFS.ReadFile(fullPath)
536
- if err != nil {
537
- // File not found - fallback to portal.html for SPA routing
538
- log.Debug().Err(err).Str("path", filePath).Msg("static file not found, serving portal.html")
539
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
540
- f.ServeStaticFile(w, r, "portal.html", "text/html; charset=utf-8")
541
- return
542
- }
543
-
544
- // Set content type
545
- if contentType != "" {
546
- w.Header().Set("Content-Type", contentType)
547
- } else {
548
- ext := path.Ext(filePath)
549
- ct := utils.GetContentType(ext)
550
- if ct != "" {
551
- w.Header().Set("Content-Type", ct)
162
+ if r.URL.Path == "/" {
163
+ // Serve portal HTML from dist/wasm
164
+ frontend.ServeStaticFile(w, r, "portal.html", "text/html; charset=utf-8")
165
+ return
166
}
553
- }
554
-
555
- log.Debug().
556
- Str("path", filePath).
557
- Int("size", len(data)).
558
- Msg("served static file")
559
-
560
- w.WriteHeader(http.StatusOK)
561
- w.Write(data)
562
-}
563
-
564
-// serveDynamicManifest generates and serves manifest.json dynamically
565
-func (f *Frontend) ServeDynamicManifest(w http.ResponseWriter, _ *http.Request) {
566
- utils.SetCORSHeaders(w)
567
-
568
- // Find the content-addressed WASM file
569
- f.wasmCacheMu.RLock()
570
- var wasmHash string
571
- var wasmFile string
572
- for filename, entry := range f.wasmCache {
573
- wasmHash = entry.hash
574
- wasmFile = filename
575
- break // Use the first (and should be only) WASM file
576
- }
577
- f.wasmCacheMu.RUnlock()
167
+ frontend.ServePortalStatic(w, r)
168
+ })
169
579
- // Fallback: scan embedded WASM directory if cache is empty
580
- if wasmHash == "" {
581
- entries, err := f.distFS.ReadDir("dist/wasm")
582
- if err == nil {
583
- for _, entry := range entries {
584
- if entry.IsDir() {
585
- continue
586
- }
587
- name := entry.Name()
588
- // Look for content-addressed WASM files: <hex>.wasm.br
589
- if strings.HasSuffix(name, ".wasm.br") {
590
- hash := strings.TrimSuffix(name, ".wasm.br")
591
- if utils.IsHexString(hash) {
592
- wasmHash = hash
593
- wasmFile = hash + ".wasm"
594
- break
595
- }
596
- }
597
- }
170
+ // routes based on host and path
171
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
172
+ // Route subdomain requests (e.g., *.example.com) to portalMux
173
+ // and everything else to the app UI mux.
174
+ if utils.IsSubdomain(flagPortalAppURL, r.Host) {
175
+ portalMux.ServeHTTP(w, r)
176
+ } else {
177
+ appMux.ServeHTTP(w, r)
178
}
599
- }
600
-
601
- // Generate WASM URL
602
- wasmURL := flagPortalURL + "/frontend/" + wasmFile
603
-
604
- // Create manifest structure
605
- manifest := map[string]string{
606
- "wasmFile": wasmFile,
607
- "wasmUrl": wasmURL,
608
- "hash": wasmHash,
609
- "bootstraps": strings.Join(flagBootstraps, ","),
610
- }
611
-
612
- // Set headers for no caching
613
- w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
614
- w.Header().Set("Pragma", "no-cache")
615
- w.Header().Set("Expires", "0")
616
- w.Header().Set("Content-Type", "application/json")
617
-
618
- // Encode and send
619
- w.WriteHeader(http.StatusOK)
620
- if err := json.NewEncoder(w).Encode(manifest); err != nil {
621
- log.Error().Err(err).Msg("Failed to encode manifest")
622
- }
623
-
624
- log.Debug().
625
- Str("wasmFile", wasmFile).
626
- Str("wasmUrl", wasmURL).
627
- Str("hash", wasmHash).
628
- Str("bootstraps", strings.Join(flagBootstraps, ",")).
629
- Msg("Served dynamic manifest")
630
-}
631
-
632
-// ServeDynamicServiceWorker serves service-worker.js with injected manifest and config.
633
-func (f *Frontend) ServeDynamicServiceWorker(w http.ResponseWriter, r *http.Request) {
634
- utils.SetCORSHeaders(w)
635
-
636
- // Read the service-worker.js template
637
- fullPath := path.Join("dist", "wasm", "service-worker.js")
638
- content, err := f.distFS.ReadFile(fullPath)
639
- if err != nil {
640
- log.Error().Err(err).Msg("Failed to read service-worker.js")
641
- http.NotFound(w, r)
642
- return
643
- }
644
-
645
- // Set headers for no caching
646
- w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
647
- w.Header().Set("Pragma", "no-cache")
648
- w.Header().Set("Expires", "0")
649
- w.Header().Set("Content-Type", "application/javascript")
650
-
651
- // Send response
652
- w.WriteHeader(http.StatusOK)
653
- w.Write(content)
179
+ })
180
655
- log.Debug().Msg("Served service-worker.js")
181
+ srv := &http.Server{
182
+ Addr: addr,
183
+ Handler: handler,
184
+ }
185
+
186
+ go func() {
187
+ log.Info().Msgf("[server] http: %s", addr)
188
+ if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
189
+ log.Error().Err(err).Msg("[server] http error")
190
+ cancel()
191
+ }
192
+ }()
193
+
194
+ return srv
195
+}
196
+
197
+type leaseRow struct {
198
+ Peer string
199
+ Name string
200
+ Kind string
201
+ Connected bool
202
+ DNS string
203
+ LastSeen string
204
+ LastSeenISO string
205
+ FirstSeenISO string
206
+ TTL string
207
+ Link string
208
+ StaleRed bool
209
+ Hide bool
210
+ Metadata string
211
+ BPS int64 // bytes-per-second limit (0 = unlimited)
212
+ IsApproved bool // whether lease is approved (for manual mode)
213
+ IsDenied bool // whether lease is denied (for manual mode)
214
+ IP string // client IP address (for IP-based ban)
215
+ IsIPBanned bool // whether the IP is banned
216
}
cmd/relay-server/view.go
deleted
-216
@@ -1,216 +0,0 @@
1
-package main
2
-
3
-import (
4
- "context"
5
- "embed"
6
- "net/http"
7
- "strings"
8
-
9
- "github.com/rs/zerolog/log"
10
-
11
- "gosuda.org/portal/cmd/relay-server/manager"
12
- "gosuda.org/portal/portal"
13
- "gosuda.org/portal/utils"
14
-)
15
-
16
-//go:embed dist/*
17
-var distFS embed.FS
18
-
19
-// serveHTTP builds the HTTP mux and returns the server.
20
-func serveHTTP(addr string, serv *portal.RelayServer, admin *Admin, frontend *Frontend, noIndex bool, cancel context.CancelFunc) *http.Server {
21
- if addr == "" {
22
- addr = ":0"
23
- }
24
-
25
- // Initialize WASM cache used by content handlers
26
- if err := frontend.InitWasmCache(); err != nil {
27
- log.Error().Err(err).Msg("failed to initialize WASM cache")
28
- }
29
-
30
- // Create app UI mux
31
- appMux := http.NewServeMux()
32
-
33
- // Serve favicons (ico/png/svg) from dist/app
34
- frontend.ServeAsset(appMux, "/favicon.ico", "favicon.ico", "image/x-icon")
35
- frontend.ServeAsset(appMux, "/favicon.png", "favicon.png", "image/png")
36
- frontend.ServeAsset(appMux, "/favicon.svg", "favicon.svg", "image/svg+xml")
37
-
38
- if noIndex {
39
- appMux.HandleFunc("/robots.txt", func(w http.ResponseWriter, r *http.Request) {
40
- w.Header().Set("Content-Type", "text/plain")
41
- w.WriteHeader(http.StatusOK)
42
- w.Write([]byte("User-agent: *\nDisallow: /\n"))
43
- })
44
- }
45
-
46
- // Portal app assets (JS, CSS, etc.) - served from /app/
47
- appMux.HandleFunc("/app/", func(w http.ResponseWriter, r *http.Request) {
48
- utils.SetCORSHeaders(w)
49
- if r.Method == http.MethodOptions {
50
- w.WriteHeader(http.StatusOK)
51
- return
52
- }
53
- p := strings.TrimPrefix(r.URL.Path, "/app/")
54
- frontend.ServeAppStatic(w, r, p, serv)
55
- })
56
-
57
- // Portal frontend files (for unified caching)
58
- appMux.HandleFunc("/frontend/", func(w http.ResponseWriter, r *http.Request) {
59
- utils.SetCORSHeaders(w)
60
- if r.Method == http.MethodOptions {
61
- w.WriteHeader(http.StatusOK)
62
- return
63
- }
64
- p := strings.TrimPrefix(r.URL.Path, "/frontend/")
65
- if p == "manifest.json" {
66
- frontend.ServeDynamicManifest(w, r)
67
- return
68
- }
69
-
70
- frontend.ServePortalStaticFile(w, r, p)
71
- })
72
-
73
- // Tunnel installer script and binaries
74
- appMux.HandleFunc("/tunnel", func(w http.ResponseWriter, r *http.Request) {
75
- serveTunnelScript(w, r)
76
- })
77
- appMux.HandleFunc("/tunnel/bin/", func(w http.ResponseWriter, r *http.Request) {
78
- serveTunnelBinary(w, r)
79
- })
80
-
81
- appMux.HandleFunc("/relay", func(w http.ResponseWriter, r *http.Request) {
82
- if r.Method != http.MethodGet {
83
- w.Header().Set("Allow", http.MethodGet)
84
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
85
- return
86
- }
87
-
88
- // Check if IP is banned
89
- clientIP := manager.ExtractClientIP(r)
90
- ipManager := admin.GetIPManager()
91
- if ipManager != nil && ipManager.IsIPBanned(clientIP) {
92
- log.Warn().Str("ip", clientIP).Msg("[server] connection rejected: IP banned")
93
- http.Error(w, "forbidden", http.StatusForbidden)
94
- return
95
- }
96
-
97
- stream, wsConn, err := utils.UpgradeToWSStream(w, r, nil)
98
- if err != nil {
99
- log.Error().Err(err).Msg("[server] websocket upgrade failed")
100
- return
101
- }
102
-
103
- // Store pending IP for lease association (will be linked when lease is registered)
104
- if ipManager != nil && clientIP != "" {
105
- ipManager.StorePendingIP(clientIP)
106
- }
107
-
108
- if err := serv.HandleConnection(stream); err != nil {
109
- log.Error().Err(err).Msg("[server] websocket relay connection error")
110
- wsConn.Close()
111
- return
112
- }
113
- })
114
-
115
- // App UI index page - serve React frontend with SSR (delegates to serveAppStatic)
116
- appMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
117
- // serveAppStatic handles both "/" and 404 fallback with SSR
118
- p := strings.TrimPrefix(r.URL.Path, "/")
119
- frontend.ServeAppStatic(w, r, p, serv)
120
- })
121
-
122
- appMux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
123
- w.WriteHeader(http.StatusOK)
124
- w.Write([]byte("{\"status\":\"ok\"}"))
125
- })
126
-
127
- // Admin API
128
- appMux.HandleFunc("/admin/", func(w http.ResponseWriter, r *http.Request) {
129
- admin.HandleAdminRequest(w, r, serv)
130
- })
131
-
132
- // Create portal frontend mux (routes only)
133
- portalMux := http.NewServeMux()
134
-
135
- // Static file handler for /frontend/ (for unified caching)
136
- portalMux.HandleFunc("/frontend/", func(w http.ResponseWriter, r *http.Request) {
137
- utils.SetCORSHeaders(w)
138
- if r.Method == http.MethodOptions {
139
- w.WriteHeader(http.StatusOK)
140
- return
141
- }
142
- p := strings.TrimPrefix(r.URL.Path, "/frontend/")
143
- if p == "manifest.json" {
144
- frontend.ServeDynamicManifest(w, r)
145
- return
146
- }
147
- frontend.ServePortalStaticFile(w, r, p)
148
- })
149
-
150
- // Service worker for portal subdomains (serve from dist/wasm)
151
- portalMux.HandleFunc("/service-worker.js", func(w http.ResponseWriter, r *http.Request) {
152
- frontend.ServeDynamicServiceWorker(w, r)
153
- })
154
-
155
- // Root and SPA fallback for portal subdomains
156
- portalMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
157
- utils.SetCORSHeaders(w)
158
- if r.Method == http.MethodOptions {
159
- w.WriteHeader(http.StatusOK)
160
- return
161
- }
162
- if r.URL.Path == "/" {
163
- // Serve portal HTML from dist/wasm
164
- frontend.ServeStaticFile(w, r, "portal.html", "text/html; charset=utf-8")
165
- return
166
- }
167
- frontend.ServePortalStatic(w, r)
168
- })
169
-
170
- // routes based on host and path
171
- handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
172
- // Route subdomain requests (e.g., *.example.com) to portalMux
173
- // and everything else to the app UI mux.
174
- if utils.IsSubdomain(flagPortalAppURL, r.Host) {
175
- portalMux.ServeHTTP(w, r)
176
- } else {
177
- appMux.ServeHTTP(w, r)
178
- }
179
- })
180
-
181
- srv := &http.Server{
182
- Addr: addr,
183
- Handler: handler,
184
- }
185
-
186
- go func() {
187
- log.Info().Msgf("[server] http: %s", addr)
188
- if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
189
- log.Error().Err(err).Msg("[server] http error")
190
- cancel()
191
- }
192
- }()
193
-
194
- return srv
195
-}
196
-
197
-type leaseRow struct {
198
- Peer string
199
- Name string
200
- Kind string
201
- Connected bool
202
- DNS string
203
- LastSeen string
204
- LastSeenISO string
205
- FirstSeenISO string
206
- TTL string
207
- Link string
208
- StaleRed bool
209
- Hide bool
210
- Metadata string
211
- BPS int64 // bytes-per-second limit (0 = unlimited)
212
- IsApproved bool // whether lease is approved (for manual mode)
213
- IsDenied bool // whether lease is denied (for manual mode)
214
- IP string // client IP address (for IP-based ban)
215
- IsIPBanned bool // whether the IP is banned
216
-}