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