server: update view

Kim committed Oct 30, 2025 at 15:54 UTC 799eb075c47176c34cc844354b495a31af9b3bb4
17 files changed +232 -1864
cmd/demo-app/main.go
+1 -1
@@ -36,7 +36,7 @@ func init() {
36 flags := rootCmd.PersistentFlags()
37 flags.StringVar(&flagServerURL, "server-url", "ws://localhost:4017/relay", "relay websocket URL")
38 flags.IntVar(&flagPort, "port", 8092, "local paint HTTP port")
39 - flags.StringVar(&flagName, "name", "example-paint", "backend display name")
39 + flags.StringVar(&flagName, "name", "demo-app", "backend display name")
40 }
41
42 func main() {
cmd/relay-server/favicon/site.webmanifest deleted
-21
@@ -1,21 +0,0 @@
1 -{
2 - "name": "MyWebSite",
3 - "short_name": "MySite",
4 - "icons": [
5 - {
6 - "src": "/web-app-manifest-192x192.png",
7 - "sizes": "192x192",
8 - "type": "image/png",
9 - "purpose": "maskable"
10 - },
11 - {
12 - "src": "/web-app-manifest-512x512.png",
13 - "sizes": "512x512",
14 - "type": "image/png",
15 - "purpose": "maskable"
16 - }
17 - ],
18 - "theme_color": "#ffffff",
19 - "background_color": "#ffffff",
20 - "display": "standalone"
21 -}
\ No newline at end of file
cmd/relay-server/static/favicon/apple-touch-icon.png renamed
cmd/relay-server/static/favicon/favicon-96x96.png renamed
cmd/relay-server/static/favicon/favicon.ico renamed
cmd/relay-server/static/favicon/favicon.svg renamed
cmd/relay-server/static/favicon/web-app-manifest-192x192.png renamed
cmd/relay-server/static/favicon/web-app-manifest-512x512.png renamed
cmd/relay-server/static/index.html new
+81
@@ -0,0 +1,81 @@
1 +<!doctype html>
2 +<html lang="ko">
3 +<head>
4 + <meta charset="utf-8"/>
5 + <meta name="viewport" content="width=device-width, initial-scale=1" />
6 + <title>Portal</title>
7 + <link rel="icon" type="image/x-icon" href="/favicon.ico" />
8 + <link rel="stylesheet" href="/static/style.css" />
9 + </head>
10 +<body>
11 + <div class="wrap">
12 + <header>
13 + <div class="brand">Portal</div>
14 + <div class="rightbar">
15 + <div class="searchbar" role="search">
16 + <svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
17 + <path d="M15.5 14h-.79l-.28-.27a6.471 6.471 0 0 0 1.57-4.23C15.99 6.01 13.48 3.5 10.49 3.5S5 6.01 5 9s2.51 5.5 5.5 5.5c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l4.25 4.25c.41.41 1.08.41 1.49 0 .41-.41.41-1.08 0-1.49L15.5 14Zm-5 0C8.01 14 6 11.99 6 9.5S8.01 5 10.5 5 15 7.01 15 9.5 12.99 14 10.5 14Z"/>
18 + </svg>
19 + <input id="search" type="text" placeholder="Search by name" aria-label="Search by name" oninput="filterCards(this.value)">
20 + </div>
21 + <a class="gh-btn" href="https://github.com/gosuda/portal" target="_blank" rel="noopener" aria-label="GitHub repository" title="gosuda/portal">
22 + <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
23 + <path d="M12 .5C5.73.5.5 5.73.5 12c0 5.08 3.29 9.37 7.86 10.88.58.1.79-.25.79-.56 0-.27-.01-1.16-.02-2.11-3.2.69-3.88-1.39-3.88-1.39-.53-1.35-1.29-1.71-1.29-1.71-1.05-.72.08-.7.08-.7 1.16.08 1.78 1.19 1.78 1.19 1.03 1.77 2.7 1.26 3.36.96.1-.75.4-1.26.73-1.55-2.56-.29-5.26-1.28-5.26-5.72 0-1.26.45-2.3 1.19-3.11-.12-.29-.52-1.45.11-3.02 0 0 .98-.31 3.2 1.19.93-.26 1.94-.39 2.94-.39s2.01.13 2.95.39c2.22-1.5 3.2-1.19 3.2-1.19.63 1.57.23 2.73.12 3.02.74.81 1.19 1.85 1.19 3.11 0 4.45-2.7 5.43-5.28 5.72.41.36.77 1.07.77 2.16 0 1.56-.01 2.81-.01 3.19 0 .31.21.67.8.56C20.22 21.36 23.5 17.08 23.5 12 23.5 5.73 18.27.5 12 .5Z"/>
24 + </svg>
25 + </a>
26 + <div class="counter" aria-label="Active devices" title="Active devices">
27 + <svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
28 + <circle cx="12" cy="12" r="6"/>
29 + </svg>
30 + <span>{{len .Rows}}</span>
31 + </div>
32 + </div>
33 + </header>
34 + <main>
35 + <div class="grid">
36 + {{range .Rows}}
37 + <a class="card {{if .Connected}}connected{{else}}disconnected{{end}}" id="peer-{{.Peer}}" data-peer="{{.Peer}}" data-name="{{.Name}}" href="{{.Link}}" title="{{if .Name}}{{.Name}}{{else}}(unnamed){{end}} 열기">
38 + <div class="card-head">
39 + <span class="status-dot {{if .Connected}}ok{{else}}{{if .StaleRed}}bad{{end}}{{end}}"></span>
40 + <div class="title">{{if .Name}}{{.Name}}{{else}}(unnamed){{end}}</div>
41 + <svg class="chevron" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
42 + <path d="M9 6l6 6-6 6"/>
43 + </svg>
44 + </div>
45 + <div class="muted">Last seen <time class="lastseen" datetime="{{.LastSeenISO}}">{{.LastSeenISO}}</time><br/>Active ~{{.LastSeen}} ago</div>
46 + </a>
47 + {{else}}
48 + <article class="card">
49 + <div class="title">No clients discovered</div>
50 + <div class="muted">Start a client and ensure bootstrap URLs point at this server's /relay WebSocket endpoint.</div>
51 + </article>
52 + {{end}}
53 + </div>
54 + </main>
55 + </div>
56 + <script>
57 + function filterCards(q) {
58 + q = (q || '').toLowerCase().trim();
59 + const cards = document.querySelectorAll('.grid .card');
60 + cards.forEach(card => {
61 + const name = (card.getAttribute('data-name') || '').toLowerCase();
62 + const show = !q || name.includes(q);
63 + card.style.display = show ? '' : 'none';
64 + });
65 + }
66 + (function () {
67 + try {
68 + const opts = { dateStyle: 'medium', timeStyle: 'short' };
69 + document.querySelectorAll('time.lastseen').forEach(el => {
70 + const iso = el.getAttribute('datetime');
71 + if (!iso) return;
72 + const d = new Date(iso);
73 + if (isNaN(d.getTime())) return;
74 + el.textContent = new Intl.DateTimeFormat(undefined, opts).format(d);
75 + el.title = d.toString();
76 + });
77 + } catch (_) {}
78 + })();
79 + </script>
80 +</body>
81 +</html>
cmd/relay-server/static/style.css new
+68
@@ -0,0 +1,68 @@
1 +* { box-sizing: border-box }
2 +:root {
3 + --bg:#fafbff; --panel:#ffffff; --ink:#0f172a; --muted:#6b7280; --line:#e9eef5;
4 + --primary:#2563eb; --ok:#059669; --bad:#b91c1c; --ok-bg:#ecfdf5; --bad-bg:#fee2e2;
5 +
6 + --radius:14px; --radius-pill:999px;
7 + --shadow:0 1px 2px rgba(15,23,42,0.06);
8 + --shadow-hover:0 6px 20px rgba(15,23,42,0.12);
9 + --transition:.2s ease;
10 + --gap:16px; --pad:16px; --header-pad:20px 24px; --wrap-max:980px;
11 + --fs-base:16px; --fs-muted:13px;
12 + --font:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,"Apple Color Emoji","Segoe UI Emoji";
13 +}
14 +
15 +body { margin:0; background:var(--bg); color:var(--ink); font-family:var(--font); font-size:var(--fs-base); line-height:1.6 }
16 +.wrap { max-width:var(--wrap-max); margin:0 auto; padding: calc(var(--pad) * 2) 20px }
17 +header { display:flex; align-items:center; gap:var(--gap); justify-content:space-between; padding:var(--header-pad); background:var(--panel); border:1px solid var(--line); border-radius:var(--radius) }
18 +.brand { display:flex; align-items:center; height:36px; font-weight:800; font-size:24px; letter-spacing:.2px }
19 +.status { color:var(--ok); font-weight:700 }
20 +.app-left { display:flex; align-items:center; gap:12px }
21 +main { margin-top: 22px }
22 +.section { background:var(--panel); border:1px solid var(--line); border-radius:var(--radius); padding:18px; margin-bottom:14px }
23 +.grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap:var(--gap); margin-top:14px }
24 +
25 +.card { background:var(--panel); border:1px solid var(--line); border-radius:var(--radius); padding:var(--pad); display:flex; flex-direction:column; box-shadow:var(--shadow); transition: box-shadow var(--transition), transform var(--transition); text-decoration:none; color:inherit; cursor:pointer; min-height:180px }
26 +.card:hover { box-shadow:var(--shadow-hover); transform: translateY(-2px) }
27 +.card-head { display:flex; align-items:center; gap:10px; margin-bottom:8px }
28 +.chevron { margin-left:auto; width:16px; height:16px; color:var(--muted); opacity:.9; transition: transform var(--transition), color var(--transition), opacity var(--transition) }
29 +.card:hover .chevron { transform: translateX(2px); color:var(--primary); opacity:1 }
30 +.status-dot { width:10px; height:10px; border-radius:var(--radius-pill); background:var(--muted) }
31 +.status-dot.ok { background: var(--ok) }
32 +.status-dot.bad { background: var(--bad) }
33 +.title { font-weight:800; margin:0; font-size:18px }
34 +.muted { color:var(--muted); font-size:var(--fs-muted) }
35 +.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: var(--fs-muted); color:#374151; word-break: break-all }
36 +
37 +.pill { display:inline-flex; align-items:center; gap:8px; padding:6px 10px; border-radius:var(--radius-pill); font-weight:800; font-size:var(--fs-muted) }
38 +.pill.ok { background:var(--ok-bg); color:var(--ok) }
39 +.pill.bad { background:var(--bad-bg); color:var(--bad) }
40 +.pill .dot { width:8px; height:8px; border-radius:var(--radius-pill); background:var(--ok); display:inline-block }
41 +.pill.bad .dot { background:var(--bad) }
42 +.head { display:flex; align-items:center; justify-content:space-between; gap:12px }
43 +.btn { display:inline-block; background:var(--primary); color:#fff; text-decoration:none; border-radius:8px; padding:6px 10px; font-weight:700; font-size:var(--fs-muted); line-height:1; margin-top:6px; transition: filter var(--transition) }
44 +.btn:hover { filter: brightness(0.95) }
45 +.btn.outline { background:transparent; color:var(--primary); border:1px solid var(--primary) }
46 +.actions { margin-top:auto; display:flex; justify-content:flex-end }
47 +.card:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px }
48 +
49 +.searchbar { width:320px; max-width:50vw; margin:0; display:flex; align-items:center; gap:10px; height:36px; padding:0 12px; border:1px solid var(--line); border-radius:var(--radius-pill); background:#fff }
50 +.searchbar:focus-within { box-shadow: 0 1px 6px rgba(32,33,36,.28); border-color:#dfe1e5 }
51 +.searchbar input { width:100%; height:100%; border:none; outline:none; font-size:14px; background:transparent; color:#111 }
52 +.searchbar .icon { width:18px; height:18px; color:#9aa0a6 }
53 +.topbar { display:flex; align-items:center; gap:var(--gap); justify-content:space-between }
54 +.rightbar { display:flex; align-items:center; gap:12px; margin-left:auto }
55 +.gh-btn { width:36px; height:36px; border-radius:var(--radius-pill); display:inline-flex; align-items:center; justify-content:center; color:#000; background:#fff; border:1px solid var(--line); transition: box-shadow var(--transition), transform var(--transition) }
56 +.gh-btn:hover { box-shadow:var(--shadow) }
57 +.counter { display:inline-flex; align-items:center; gap:6px; height:36px; padding:0 10px; border-radius:var(--radius-pill); background:var(--ok-bg); color:var(--ok); font-weight:800 }
58 +.counter .icon { width:18px; height:18px }
59 +
60 +@media (max-width: 640px) {
61 + .wrap { padding: 20px 14px }
62 + header { flex-wrap: wrap; gap: 12px; padding: 16px }
63 + .brand { font-size: 16px }
64 + .rightbar { margin-left: 0; width: 100%; flex-wrap: wrap; gap: 8px }
65 + .searchbar { flex: 1 1 100%; width: 100%; max-width: 100% }
66 + .gh-btn { width: 32px; height: 32px }
67 + .counter { height: 32px; padding: 0 8px; font-size: 12px }
68 +}
cmd/relay-server/view.go
+82 -179
@@ -6,6 +6,7 @@ import (
6 "encoding/json"
7 "fmt"
8 "html/template"
9 + "io/fs"
10 "net"
11 "net/http"
12 "net/http/httputil"
@@ -23,8 +24,8 @@ import (
24 "github.com/gosuda/portal/sdk"
25 )
26
26 -//go:embed favicon/*
27 -var faviconFS embed.FS
27 +//go:embed static
28 +var assetsFS embed.FS
29
30 // serveHTTP builds the HTTP mux and returns the server.
31 func serveHTTP(_ context.Context, addr string, serv *portal.RelayServer, nodeID string, bootstraps []string, cancel context.CancelFunc) *http.Server {
@@ -34,37 +35,32 @@ func serveHTTP(_ context.Context, addr string, serv *portal.RelayServer, nodeID
35
36 mux := http.NewServeMux()
37
37 - // Serve embedded favicons (ico/png/svg) if present
38 - mux.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
39 - b, err := faviconFS.ReadFile("favicon/favicon.ico")
40 - if err != nil {
41 - http.NotFound(w, r)
42 - return
43 - }
44 - w.Header().Set("Content-Type", "image/x-icon")
45 - w.WriteHeader(http.StatusOK)
46 - _, _ = w.Write(b)
47 - })
48 - mux.HandleFunc("/favicon.png", func(w http.ResponseWriter, r *http.Request) {
49 - b, err := faviconFS.ReadFile("favicon/favicon.png")
50 - if err != nil {
51 - http.NotFound(w, r)
52 - return
53 - }
54 - w.Header().Set("Content-Type", "image/png")
55 - w.WriteHeader(http.StatusOK)
56 - _, _ = w.Write(b)
57 - })
58 - mux.HandleFunc("/favicon.svg", func(w http.ResponseWriter, r *http.Request) {
59 - b, err := faviconFS.ReadFile("favicon/favicon.svg")
60 - if err != nil {
61 - http.NotFound(w, r)
62 - return
63 - }
64 - w.Header().Set("Content-Type", "image/svg+xml")
65 - w.WriteHeader(http.StatusOK)
66 - _, _ = w.Write(b)
67 - })
38 + // Parse template once per server instance (no global var)
39 + tmpl := template.Must(template.ParseFS(assetsFS, "static/index.html"))
40 +
41 + // Serve static assets under /static/ (CSS, images)
42 + if sub, err := fs.Sub(assetsFS, "static"); err == nil {
43 + mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(sub))))
44 + }
45 +
46 + // Serve embedded favicons (ico/png/svg)
47 + serveAsset := func(route, assetPath, contentType string) {
48 + mux.HandleFunc(route, func(w http.ResponseWriter, r *http.Request) {
49 + b, err := assetsFS.ReadFile(assetPath)
50 + if err != nil {
51 + http.NotFound(w, r)
52 + return
53 + }
54 + if contentType != "" {
55 + w.Header().Set("Content-Type", contentType)
56 + }
57 + w.WriteHeader(http.StatusOK)
58 + _, _ = w.Write(b)
59 + })
60 + }
61 + serveAsset("/favicon.ico", "static/favicon/favicon.ico", "image/x-icon")
62 + serveAsset("/favicon.png", "static/favicon/favicon.png", "image/png")
63 + serveAsset("/favicon.svg", "static/favicon/favicon.svg", "image/svg+xml")
64
65 // Per-peer HTTP reverse proxy over Portal
66 // Route: /peer/{leaseID}/*
@@ -220,7 +216,7 @@ func serveHTTP(_ context.Context, addr string, serv *portal.RelayServer, nodeID
216
217 w.Header().Set("Content-Type", "text/html; charset=utf-8")
218 log.Debug().Msg("render admin index")
223 - if err := serverTmpl.Execute(w, data); err != nil {
219 + if err := tmpl.Execute(w, data); err != nil {
220 log.Error().Err(err).Msg("[server] render admin index")
221 }
222 })
@@ -251,14 +247,16 @@ func serveHTTP(_ context.Context, addr string, serv *portal.RelayServer, nodeID
247 }
248
249 type leaseRow struct {
254 - Peer string
255 - Name string
256 - Kind string
257 - Connected bool
258 - DNS string
259 - LastSeen string
260 - TTL string
261 - Link string
250 + Peer string
251 + Name string
252 + Kind string
253 + Connected bool
254 + DNS string
255 + LastSeen string
256 + LastSeenISO string
257 + TTL string
258 + Link string
259 + StaleRed bool
260 }
261
262 type adminPageData struct {
@@ -297,12 +295,41 @@ func convertLeaseEntriesToRows(serv *portal.RelayServer) []leaseRow {
295 }
296 }
297
300 - // Format last seen time
301 - lastSeenStr := leaseEntry.LastSeen.Format("2006-01-02 15:04:05")
298 + // Format last active as relative time (e.g., "1h 4m", "12m 5s", "8s")
299 + since := now.Sub(leaseEntry.LastSeen)
300 + if since < 0 {
301 + since = 0
302 + }
303 + lastSeenStr := func(d time.Duration) string {
304 + if d >= time.Hour {
305 + h := int(d / time.Hour)
306 + m := int((d % time.Hour) / time.Minute)
307 + if m > 0 {
308 + return fmt.Sprintf("%dh %dm", h, m)
309 + }
310 + return fmt.Sprintf("%dh", h)
311 + }
312 + if d >= time.Minute {
313 + m := int(d / time.Minute)
314 + s := int((d % time.Minute) / time.Second)
315 + if s > 0 {
316 + return fmt.Sprintf("%dm %ds", m, s)
317 + }
318 + return fmt.Sprintf("%dm", m)
319 + }
320 + s := int(d / time.Second)
321 + return fmt.Sprintf("%ds", s)
322 + }(since)
323 + lastSeenISO := leaseEntry.LastSeen.UTC().Format(time.RFC3339)
324
303 - // Check if connection is still active by checking if the connection ID exists in the connections map
325 + // Check if connection is still active
326 connected := serv.IsConnectionActive(leaseEntry.ConnectionID)
327
328 + // Skip entries that have been disconnected for 3 minutes or more
329 + if !connected && since >= 3*time.Minute {
330 + continue
331 + }
332 +
333 // Use name from lease if available
334 name := lease.Name
335 if name == "" {
@@ -330,14 +357,16 @@ func convertLeaseEntriesToRows(serv *portal.RelayServer) []leaseRow {
357 link := fmt.Sprintf("/peer/%s", linkPath)
358
359 row := leaseRow{
333 - Peer: identityID,
334 - Name: name,
335 - Kind: kind,
336 - Connected: connected,
337 - DNS: dnsLabel,
338 - LastSeen: lastSeenStr,
339 - TTL: ttlStr,
340 - Link: link,
360 + Peer: identityID,
361 + Name: name,
362 + Kind: kind,
363 + Connected: connected,
364 + DNS: dnsLabel,
365 + LastSeen: lastSeenStr,
366 + LastSeenISO: lastSeenISO,
367 + TTL: ttlStr,
368 + Link: link,
369 + StaleRed: !connected && since >= 15*time.Second,
370 }
371
372 rows = append(rows, row)
@@ -353,129 +382,3 @@ var wsUpgrader = websocket.Upgrader{
382 return true
383 },
384 }
356 -
357 -var serverTmpl = template.Must(template.New("admin-index").Parse(`<!doctype html>
358 -<html lang="ko">
359 -<head>
360 - <meta charset="utf-8"/>
361 - <meta name="viewport" content="width=device-width, initial-scale=1" />
362 - <title>Portal</title>
363 - <link rel="icon" type="image/x-icon" href="/favicon.ico" />
364 - <style>
365 - * { box-sizing: border-box }
366 - :root {
367 - --bg:#fafbff; --panel:#ffffff; --ink:#0f172a; --muted:#6b7280; --line:#e9eef5;
368 - --primary:#2563eb; --ok:#059669; --bad:#b91c1c; --ok-bg:#ecfdf5; --bad-bg:#fee2e2;
369 - }
370 - body { margin:0; background:var(--bg); color:var(--ink); font-family:sans-serif; font-size:16px; line-height:1.6 }
371 - .wrap { max-width: 980px; margin: 0 auto; padding: 32px 20px }
372 - header { display:flex; align-items:center; gap:16px; justify-content:space-between; padding: 20px 24px; background:var(--panel); border:1px solid var(--line); border-radius: 14px }
373 - .brand { display:flex; align-items:center; height:36px; font-weight:800; font-size:22px; letter-spacing:.2px }
374 - .status { color:var(--ok); font-weight:700 }
375 - .app-left { display:flex; align-items:center; gap:12px }
376 - main { margin-top: 22px }
377 - .section { background:var(--panel); border:1px solid var(--line); border-radius:14px; padding:18px; margin-bottom:14px }
378 - .grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap:16px; margin-top:14px }
379 - .card { background:var(--panel); border:1px solid var(--line); border-radius:14px; padding:16px; display:flex; flex-direction:column; box-shadow: 0 1px 2px rgba(15,23,42,0.06); transition: box-shadow .2s ease, transform .2s ease; text-decoration:none; color:inherit; cursor:pointer; min-height: 180px }
380 - .card:hover { box-shadow: 0 6px 20px rgba(15,23,42,0.12); transform: translateY(-2px) }
381 - .card-head { display:flex; align-items:center; gap:10px; margin-bottom:8px }
382 - .chevron { margin-left:auto; width:16px; height:16px; color:var(--muted); opacity:.9; transition: transform .2s ease, color .2s ease, opacity .2s ease }
383 - .card:hover .chevron { transform: translateX(2px); color:var(--primary); opacity:1 }
384 - .status-dot { width:10px; height:10px; border-radius:999px; background:var(--muted) }
385 - .status-dot.ok { background: var(--ok) }
386 - .status-dot.bad { background: var(--bad) }
387 - .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; color:#374151; word-break: break-all }
388 - .title { font-weight:800; margin:0; font-size:16px }
389 - .muted { color:var(--muted); font-size:13px }
390 - .pill { display:inline-flex; align-items:center; gap:8px; padding:6px 10px; border-radius:999px; font-weight:800; font-size:13px }
391 - .pill.ok { background:var(--ok-bg); color:var(--ok) }
392 - .pill.bad { background:var(--bad-bg); color:var(--bad) }
393 - .pill .dot { width:8px; height:8px; border-radius:999px; background:var(--ok); display:inline-block }
394 - .pill.bad .dot { background:var(--bad) }
395 - .head { display:flex; align-items:center; justify-content:space-between; gap:12px }
396 - .btn { display:inline-block; background:var(--primary); color:#fff; text-decoration:none; border-radius:8px; padding:6px 10px; font-weight:700; font-size:13px; line-height:1; margin-top:6px }
397 - .btn.outline { background:transparent; color:var(--primary); border:1px solid var(--primary) }
398 - .actions { margin-top:auto; display:flex; justify-content:flex-end }
399 - .card:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px }
400 - /* Google-like search bar */
401 - .searchbar { width: 320px; max-width: 50vw; margin: 0; display:flex; align-items:center; gap:10px; height:36px; padding:0 12px; border:1px solid var(--line); border-radius:999px; background:#fff }
402 - .searchbar:focus-within { box-shadow: 0 1px 6px rgba(32,33,36,.28); border-color:#dfe1e5 }
403 - .searchbar input { width:100%; height:100%; border:none; outline:none; font-size:14px; background:transparent; color:#111 }
404 - .searchbar .icon { width:18px; height:18px; color:#9aa0a6 }
405 - .topbar { display:flex; align-items:center; gap:16px; justify-content:space-between }
406 - .rightbar { display:flex; align-items:center; gap:12px; margin-left:auto }
407 - .gh-btn { width:36px; height:36px; border-radius:999px; display:inline-flex; align-items:center; justify-content:center; color:#000; background:#fff; border:1px solid var(--line) }
408 - .counter { display:inline-flex; align-items:center; gap:6px; height:36px; padding:0 10px; border-radius:999px; background:var(--ok-bg); color:var(--ok); font-weight:800 }
409 - .counter .icon { width:18px; height:18px }
410 -
411 - /* Responsive: prevent header overflow on small screens */
412 - @media (max-width: 640px) {
413 - .wrap { padding: 20px 14px }
414 - header { flex-wrap: wrap; gap: 12px; padding: 16px }
415 - .brand { font-size: 16px }
416 - .rightbar { margin-left: 0; width: 100%; flex-wrap: wrap; gap: 8px }
417 - .searchbar { flex: 1 1 100%; width: 100%; max-width: 100% }
418 - .gh-btn { width: 32px; height: 32px }
419 - .counter { height: 32px; padding: 0 8px; font-size: 12px }
420 - }
421 - </style>
422 - </head>
423 -<body>
424 - <div class="wrap">
425 - <header>
426 - <div class="brand">Portal</div>
427 - <div class="rightbar">
428 - <div class="searchbar" role="search">
429 - <svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
430 - <path d="M15.5 14h-.79l-.28-.27a6.471 6.471 0 0 0 1.57-4.23C15.99 6.01 13.48 3.5 10.49 3.5S5 6.01 5 9s2.51 5.5 5.5 5.5c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l4.25 4.25c.41.41 1.08.41 1.49 0 .41-.41.41-1.08 0-1.49L15.5 14Zm-5 0C8.01 14 6 11.99 6 9.5S8.01 5 10.5 5 15 7.01 15 9.5 12.99 14 10.5 14Z"/>
431 - </svg>
432 - <input id="search" type="text" placeholder="Search by name" aria-label="Search by name" oninput="filterCards(this.value)">
433 - </div>
434 - <a class="gh-btn" href="https://github.com/gosuda/portal" target="_blank" rel="noopener" aria-label="GitHub repository" title="gosuda/portal">
435 - <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
436 - <path d="M12 .5C5.73.5.5 5.73.5 12c0 5.08 3.29 9.37 7.86 10.88.58.1.79-.25.79-.56 0-.27-.01-1.16-.02-2.11-3.2.69-3.88-1.39-3.88-1.39-.53-1.35-1.29-1.71-1.29-1.71-1.05-.72.08-.7.08-.7 1.16.08 1.78 1.19 1.78 1.19 1.03 1.77 2.7 1.26 3.36.96.1-.75.4-1.26.73-1.55-2.56-.29-5.26-1.28-5.26-5.72 0-1.26.45-2.3 1.19-3.11-.12-.29-.52-1.45.11-3.02 0 0 .98-.31 3.2 1.19.93-.26 1.94-.39 2.94-.39s2.01.13 2.95.39c2.22-1.5 3.2-1.19 3.2-1.19.63 1.57.23 2.73.12 3.02.74.81 1.19 1.85 1.19 3.11 0 4.45-2.7 5.43-5.28 5.72.41.36.77 1.07.77 2.16 0 1.56-.01 2.81-.01 3.19 0 .31.21.67.8.56C20.22 21.36 23.5 17.08 23.5 12 23.5 5.73 18.27.5 12 .5Z"/>
437 - </svg>
438 - </a>
439 - <div class="counter" aria-label="Active devices" title="Active devices">
440 - <svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
441 - <circle cx="12" cy="12" r="6"/>
442 - </svg>
443 - <span>{{len .Rows}}</span>
444 - </div>
445 - </div>
446 - </header>
447 - <main>
448 - <div class="grid">
449 - {{range .Rows}}
450 - <a class="card {{if .Connected}}connected{{else}}disconnected{{end}}" id="peer-{{.Peer}}" data-peer="{{.Peer}}" data-name="{{.Name}}" href="{{.Link}}" title="{{if .Name}}{{.Name}}{{else}}(unnamed){{end}} 열기">
451 - <div class="card-head">
452 - <span class="status-dot {{if .Connected}}ok{{else}}bad{{end}}"></span>
453 - <div class="title">{{if .Name}}{{.Name}}{{else}}(unnamed){{end}}</div>
454 - <svg class="chevron" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
455 - <path d="M9 6l6 6-6 6"/>
456 - </svg>
457 - </div>
458 - <div class="muted">Last seen: {{.LastSeen}}</div>
459 - </a>
460 - {{else}}
461 - <article class="card">
462 - <div class="title">No clients discovered</div>
463 - <div class="muted">Start a client and ensure bootstrap URLs point at this server's /relay WebSocket endpoint.</div>
464 - </article>
465 - {{end}}
466 - </div>
467 - </main>
468 - </div>
469 - <script>
470 - function filterCards(q) {
471 - q = (q || '').toLowerCase().trim();
472 - const cards = document.querySelectorAll('.grid .card');
473 - cards.forEach(card => {
474 - const name = (card.getAttribute('data-name') || '').toLowerCase();
475 - const show = !q || name.includes(q);
476 - card.style.display = show ? '' : 'none';
477 - });
478 - }
479 - </script>
480 -</body>
481 -</html>`))
cmd/relay-server/wasm/README.md deleted
-245
@@ -1,245 +0,0 @@
1 -# Portal WASM SDK
2 -
3 -WebAssembly SDK for Portal with **mandatory End-to-End Encryption (E2EE) Proxy** functionality.
4 -
5 -## Overview
6 -
7 -This WASM SDK provides browser-native E2EE proxy capabilities through Service Worker interception. All network traffic is automatically encrypted client-side before being relayed through the server.
8 -
9 -## Features
10 -
11 -- 🔒 **E2EE Proxy (Mandatory)**: Service Worker intercepts all fetch() requests and encrypts them client-side
12 -- 🔐 **Strong Encryption**: Ed25519 key exchange + ChaCha20-Poly1305 authenticated encryption
13 -- 🌐 **WebSocket Transport**: Real-time bidirectional E2EE tunnels
14 -- 📦 **Protocol Support**: HTTP, WebSocket, and TCP proxying through encrypted channels
15 -- 🎯 **Browser Native**: Runs directly in browser using WebAssembly
16 -- ⚡ **High Performance**: Compiled Rust code optimized for WASM
17 -- 🔄 **Auto Type Detection**: Content-Type based routing (Text/File/Binary/API)
18 -
19 -## Architecture
20 -
21 -```
22 -Browser Application
23 - │ fetch()
24 - ▼
25 -Service Worker (sw-proxy.js) ← Intercepts ALL requests
26 - │
27 - ▼
28 -WASM ProxyEngine ← E2EE encryption
29 - │ E2EE WebSocket
30 - ▼
31 -Relay Server ← Relay only (cannot decrypt)
32 - │ E2EE Tunnel
33 - ▼
34 -Target Peer ← Decrypts and processes
35 -```
36 -
37 -## Building
38 -
39 -### Prerequisites
40 -
41 -- Rust toolchain (1.70+)
42 -- wasm-pack: `cargo install wasm-pack`
43 -- make (for automated builds)
44 -
45 -### Quick Build
46 -
47 -**Using Makefile (Recommended):**
48 -```bash
49 -# From repository root
50 -make build-wasm
51 -
52 -# This will:
53 -# 1. Build WASM module with wasm-pack
54 -# 2. Copy artifacts to cmd/relay-server/wasm/ (for embed)
55 -# 3. Copy Service Worker files (sw-proxy.js, sw.js)
56 -```
57 -
58 -**Manual Build:**
59 -```bash
60 -cd portal/wasm
61 -
62 -# Build WASM module
63 -wasm-pack build --target web --release
64 -
65 -# Deploy to server (copies all files to embed directory)
66 -./deploy-server.sh
67 -```
68 -
69 -**Build Server:**
70 -```bash
71 -cd ../../cmd/relay-server
72 -
73 -# Build server with embedded WASM
74 -go build -o relay-server
75 -
76 -# Run
77 -./relay-server
78 -```
79 -
80 -**Access:**
81 -- Admin UI: `http://localhost:4017/`
82 -
83 -### Docker Build
84 -
85 -```bash
86 -# From repository root
87 -docker build -t portal-server .
88 -
89 -# Run
90 -docker run -p 4017:4017 portal-server
91 -```
92 -
93 -The Dockerfile uses multi-stage builds:
94 -1. **Stage 1**: Build WASM with Rust + wasm-pack
95 -2. **Stage 2**: Build Go server with embedded WASM
96 -3. **Stage 3**: Minimal runtime image
97 -
98 -## Output Files
99 -
100 -After building, the following files are generated in `cmd/relay-server/wasm/`:
101 -
102 -```
103 -cmd/relay-server/wasm/
104 -├── portal_wasm.js # WASM JavaScript bindings
105 -├── portal_wasm_bg.wasm # WASM binary (465KB)
106 -├── portal_wasm_sw.js # Service Worker bindings
107 -├── portal_wasm.d.ts # TypeScript definitions
108 -├── sw-proxy.js # E2EE Proxy Service Worker (ESSENTIAL)
109 -└── sw.js # Basic caching Service Worker
110 -```
111 -
112 -These files are embedded in the Go server binary via `//go:embed wasm` directive.
113 -
114 -## Usage
115 -
116 -### For End Users (Browser)
117 -
118 -Simply open the page - E2EE Proxy activates automatically:
119 -
120 -```html
121 -<!-- Open: http://localhost:4017/ -->
122 -
123 -<!-- Service Worker auto-registers -->
124 -<script>
125 -navigator.serviceWorker.register('/sw-proxy.js')
126 - .then(() => console.log('E2EE Proxy activated'));
127 -</script>
128 -
129 -<!-- Now ALL fetch() requests are E2EE encrypted! -->
130 -<script>
131 -fetch('https://api.github.com/zen')
132 - .then(r => r.text())
133 - .then(console.log);
134 -// ↑ Automatically encrypted via E2EE tunnel!
135 -</script>
136 -```
137 -
138 -### For Developers (JavaScript)
139 -
140 -```javascript
141 -import init, { RelayClient } from '/pkg/portal_wasm.js';
142 -
143 -// Initialize WASM
144 -await init();
145 -
146 -// Connect to relay server
147 -const client = await RelayClient.connect('ws://localhost:4017/relay');
148 -
149 -// Register a service
150 -await client.registerLease('my-service', ['http/1.1', 'h2']);
151 -
152 -// Get server info
153 -const info = await client.getRelayInfo();
154 -console.log('Active leases:', info.leases);
155 -```
156 -
157 -### For Go Applications
158 -
159 -See [Go SDK Documentation](../../sdk/)
160 -
161 -## Documentation
162 -
163 -- **[E2EE_PROXY_INTEGRATION.md](E2EE_PROXY_INTEGRATION.md)** - Comprehensive integration guide
164 -- **[E2EE_PROXY_DEPLOYMENT.md](../../E2EE_PROXY_DEPLOYMENT.md)** - Korean deployment guide
165 -- **[SERVICE_WORKER.md](SERVICE_WORKER.md)** - Service Worker implementation details
166 -- **[BUILDING.md](BUILDING.md)** - Detailed build instructions
167 -- **[INTEGRATION_TEST_GUIDE.md](INTEGRATION_TEST_GUIDE.md)** - Testing procedures
168 -- **[USAGE.md](USAGE.md)** - API usage examples
169 -
170 -## Testing
171 -
172 -```bash
173 -# Unit tests
174 -cd portal/wasm
175 -cargo test
176 -
177 -# Integration tests
178 -./integration-test.sh
179 -
180 -# Browser test
181 -# 1. Start server: cd ../../cmd/relay-server && ./relay-server
182 -# 2. Open: http://localhost:4017
183 -# 3. Check DevTools Console for "ProxyEngine ready"
184 -```
185 -
186 -## Security
187 -
188 -### End-to-End Encryption
189 -
190 -- **Algorithm**: Ed25519 key exchange + X25519 ECDH + ChaCha20-Poly1305
191 -- **Key Management**: Ephemeral keys per connection
192 -- **Server Role**: Relay only (cannot decrypt)
193 -
194 -### Content-Type Based Type Detection
195 -
196 -Service Worker automatically determines message type:
197 -
198 -| Content-Type | Type | Handling |
199 -|-------------|------|----------|
200 -| `application/json` | Text/API | JSON serialization |
201 -| `multipart/form-data` | File | Chunked streaming |
202 -| `application/octet-stream` | Binary | Raw bytes |
203 -| `text/*` | Text | UTF-8 encoding |
204 -
205 -## Troubleshooting
206 -
207 -### Service Worker 404 Error
208 -
209 -```bash
210 -# Rebuild and deploy
211 -cd portal/wasm
212 -./deploy-server.sh
213 -cd ../../cmd/relay-server
214 -go build -o relay-server
215 -```
216 -
217 -### WASM Initialization Failed
218 -
219 -```bash
220 -# Check files are served correctly
221 -curl http://localhost:4017/pkg/portal_wasm.js
222 -curl http://localhost:4017/pkg/portal_wasm_bg.wasm
223 -curl http://localhost:4017/sw-proxy.js
224 -```
225 -
226 -### WebSocket Connection Refused
227 -
228 -```bash
229 -# Verify server is running and URL is correct
230 -# Correct: ws://localhost:4017/relay
231 -# Incorrect: ws://localhost:4017/
232 -```
233 -
234 -## Performance
235 -
236 -| Metric | Standard | E2EE Proxy | Overhead |
237 -|--------|----------|------------|----------|
238 -| First Load | 2-3s | 2.5-3.5s | +500ms (WASM init) |
239 -| Cached Load | 2s | 100ms | -95% (Service Worker) |
240 -| Request Latency | 50ms | 80ms | +30ms (encryption) |
241 -| Throughput | 100MB/s | 90MB/s | -10% (crypto) |
242 -
243 -## License
244 -
245 -MIT OR Apache-2.0
cmd/relay-server/wasm/package.json deleted
-20
@@ -1,20 +0,0 @@
1 -{
2 - "name": "portal-wasm",
3 - "type": "module",
4 - "collaborators": [
5 - "Portal Contributors"
6 - ],
7 - "description": "WebAssembly client for Portal",
8 - "version": "0.1.0",
9 - "license": "MIT OR Apache-2.0",
10 - "files": [
11 - "portal_wasm_bg.wasm",
12 - "portal_wasm.js",
13 - "portal_wasm.d.ts"
14 - ],
15 - "main": "portal_wasm.js",
16 - "types": "portal_wasm.d.ts",
17 - "sideEffects": [
18 - "./snippets/*"
19 - ]
20 -}
\ No newline at end of file
cmd/relay-server/wasm/secure-websocket-sw.js deleted
-283
@@ -1,283 +0,0 @@
1 -/**
2 - * SecureWebSocket for Service Worker
3 - *
4 - * SecureWebSocket implementation for use in Service Worker
5 - * Communicates with main thread via MessageChannel
6 - */
7 -
8 -// Service Worker global ProxyEngine (initialized in sw-proxy.js)
9 -// proxyEngine and wasmReady are provided by sw-proxy.js
10 -
11 -/**
12 - * WebSocket tunnel manager in Service Worker
13 - */
14 -class ServiceWorkerWebSocketTunnel {
15 - constructor() {
16 - this.tunnels = new Map(); // tunnelId -> tunnel info
17 - this.messageQueues = new Map(); // tunnelId -> message queue
18 - this.clients = new Map(); // tunnelId -> clientId
19 - }
20 -
21 - /**
22 - * Create WebSocket tunnel
23 - */
24 - async createTunnel(url, protocols, clientId) {
25 - if (!wasmReady || !proxyEngine) {
26 - throw new Error('WASM ProxyEngine not ready');
27 - }
28 -
29 - console.log('[SW-WebSocket] Creating tunnel:', url);
30 -
31 - try {
32 - // Open WebSocket tunnel via WASM ProxyEngine
33 - const result = await proxyEngine.open_websocket(url, protocols || []);
34 - const tunnelId = result.tunnelId;
35 - const protocol = result.protocol || '';
36 -
37 - console.log('[SW-WebSocket] Tunnel created:', tunnelId);
38 -
39 - // Store tunnel information
40 - this.tunnels.set(tunnelId, {
41 - tunnelId,
42 - url,
43 - protocol,
44 - state: 'open',
45 - created: Date.now()
46 - });
47 -
48 - this.messageQueues.set(tunnelId, []);
49 - this.clients.set(tunnelId, clientId);
50 -
51 - // Start receiving messages in background
52 - this._startReceiving(tunnelId);
53 -
54 - return {
55 - tunnelId,
56 - protocol
57 - };
58 -
59 - } catch (error) {
60 - console.error('[SW-WebSocket] Failed to create tunnel:', error);
61 - throw error;
62 - }
63 - }
64 -
65 - /**
66 - * Background message receiving loop
67 - */
68 - async _startReceiving(tunnelId) {
69 - console.log('[SW-WebSocket] Starting receive loop:', tunnelId);
70 -
71 - try {
72 - while (this.tunnels.has(tunnelId)) {
73 - const tunnel = this.tunnels.get(tunnelId);
74 - if (!tunnel || tunnel.state !== 'open') {
75 - break;
76 - }
77 -
78 - // Receive message from WASM
79 - const msg = await proxyEngine.receive_websocket_message(tunnelId);
80 -
81 - console.log('[SW-WebSocket] Received message:', msg.type);
82 -
83 - // Add to message queue
84 - const queue = this.messageQueues.get(tunnelId);
85 - if (queue) {
86 - queue.push(msg);
87 - }
88 -
89 - // Notify client
90 - this._notifyClient(tunnelId, msg);
91 -
92 - // Handle close message
93 - if (msg.type === 'close') {
94 - console.log('[SW-WebSocket] Tunnel closed:', tunnelId);
95 - this._closeTunnel(tunnelId);
96 - break;
97 - }
98 - }
99 -
100 - } catch (error) {
101 - console.error('[SW-WebSocket] Receive loop error:', error);
102 - this._closeTunnel(tunnelId, 1006, error.toString());
103 - }
104 - }
105 -
106 - /**
107 - * Notify client of message
108 - */
109 - async _notifyClient(tunnelId, message) {
110 - const clientId = this.clients.get(tunnelId);
111 - if (!clientId) return;
112 -
113 - try {
114 - const client = await self.clients.get(clientId);
115 - if (client) {
116 - client.postMessage({
117 - type: 'WEBSOCKET_MESSAGE',
118 - tunnelId,
119 - message
120 - });
121 - }
122 - } catch (error) {
123 - console.error('[SW-WebSocket] Failed to notify client:', error);
124 - }
125 - }
126 -
127 - /**
128 - * Send message
129 - */
130 - async sendMessage(tunnelId, data, isBinary) {
131 - if (!this.tunnels.has(tunnelId)) {
132 - throw new Error('Tunnel not found: ' + tunnelId);
133 - }
134 -
135 - console.log('[SW-WebSocket] Sending message:', tunnelId, isBinary ? 'binary' : 'text');
136 -
137 - try {
138 - await proxyEngine.send_websocket_message(tunnelId, data, isBinary);
139 - } catch (error) {
140 - console.error('[SW-WebSocket] Send failed:', error);
141 - throw error;
142 - }
143 - }
144 -
145 - /**
146 - * Close tunnel
147 - */
148 - async closeTunnel(tunnelId, code = 1000, reason = '') {
149 - if (!this.tunnels.has(tunnelId)) {
150 - return;
151 - }
152 -
153 - console.log('[SW-WebSocket] Closing tunnel:', tunnelId, code, reason);
154 -
155 - try {
156 - await proxyEngine.close_websocket(tunnelId, code, reason);
157 - } catch (error) {
158 - console.error('[SW-WebSocket] Close failed:', error);
159 - }
160 -
161 - this._closeTunnel(tunnelId);
162 - }
163 -
164 - /**
165 - * Internal tunnel cleanup
166 - */
167 - _closeTunnel(tunnelId, code = 1000, reason = '') {
168 - const tunnel = this.tunnels.get(tunnelId);
169 - if (tunnel) {
170 - tunnel.state = 'closed';
171 - }
172 -
173 - // Cleanup
174 - this.tunnels.delete(tunnelId);
175 - this.messageQueues.delete(tunnelId);
176 - this.clients.delete(tunnelId);
177 -
178 - console.log('[SW-WebSocket] Tunnel cleaned up:', tunnelId);
179 - }
180 -
181 - /**
182 - * Get tunnel state
183 - */
184 - getTunnelState(tunnelId) {
185 - const tunnel = this.tunnels.get(tunnelId);
186 - return tunnel ? tunnel.state : 'closed';
187 - }
188 -
189 - /**
190 - * Get all tunnel information
191 - */
192 - getAllTunnels() {
193 - return Array.from(this.tunnels.values());
194 - }
195 -}
196 -
197 -// Global tunnel manager instance
198 -let tunnelManager = null;
199 -
200 -/**
201 - * Initialize tunnel manager
202 - */
203 -function initTunnelManager() {
204 - if (!tunnelManager) {
205 - tunnelManager = new ServiceWorkerWebSocketTunnel();
206 - console.log('[SW-WebSocket] Tunnel manager initialized');
207 - }
208 - return tunnelManager;
209 -}
210 -
211 -/**
212 - * WebSocket message handler to add to Service Worker
213 - */
214 -async function handleWebSocketMessage(event) {
215 - const { type, tunnelId, url, protocols, data, isBinary, code, reason } = event.data || {};
216 - const manager = initTunnelManager();
217 -
218 - switch (type) {
219 - case 'WEBSOCKET_OPEN':
220 - try {
221 - const clientId = event.source?.id || event.clientId;
222 - const result = await manager.createTunnel(url, protocols, clientId);
223 -
224 - event.ports[0]?.postMessage({
225 - success: true,
226 - result
227 - });
228 - } catch (error) {
229 - event.ports[0]?.postMessage({
230 - success: false,
231 - error: error.toString()
232 - });
233 - }
234 - break;
235 -
236 - case 'WEBSOCKET_SEND':
237 - try {
238 - await manager.sendMessage(tunnelId, data, isBinary);
239 - event.ports[0]?.postMessage({ success: true });
240 - } catch (error) {
241 - event.ports[0]?.postMessage({
242 - success: false,
243 - error: error.toString()
244 - });
245 - }
246 - break;
247 -
248 - case 'WEBSOCKET_CLOSE':
249 - try {
250 - await manager.closeTunnel(tunnelId, code, reason);
251 - event.ports[0]?.postMessage({ success: true });
252 - } catch (error) {
253 - event.ports[0]?.postMessage({
254 - success: false,
255 - error: error.toString()
256 - });
257 - }
258 - break;
259 -
260 - case 'WEBSOCKET_STATE':
261 - const state = manager.getTunnelState(tunnelId);
262 - event.ports[0]?.postMessage({
263 - success: true,
264 - state
265 - });
266 - break;
267 -
268 - case 'WEBSOCKET_LIST':
269 - const tunnels = manager.getAllTunnels();
270 - event.ports[0]?.postMessage({
271 - success: true,
272 - tunnels
273 - });
274 - break;
275 -
276 - default:
277 - return false; // Not handled
278 - }
279 -
280 - return true; // Handled
281 -}
282 -
283 -console.log('[SW-WebSocket] Service Worker WebSocket module loaded');
cmd/relay-server/wasm/secure-websocket.js deleted
-710
@@ -1,710 +0,0 @@
1 -/**
2 - * SecureWebSocket - E2EE WebSocket Polyfill using WASM ProxyEngine
3 - *
4 - * Provides transparent end-to-end encryption for WebSocket connections
5 - * through the Portal WASM ProxyEngine.
6 - */
7 -
8 -// Global WASM instance cache
9 -let wasmInstance = null;
10 -let wasmInitPromise = null;
11 -
12 -/**
13 - * Get Relay server URL from server or config
14 - * @returns {Promise<string>} Relay server WebSocket URL
15 - */
16 -async function getRelayUrl() {
17 - // 1. Check if manually configured
18 - if (window.PORTAL_RELAY_URL) {
19 - console.log('[SecureWebSocket] Using configured relay URL:', window.PORTAL_RELAY_URL);
20 - return window.PORTAL_RELAY_URL;
21 - }
22 -
23 - // 2. Try to get from server API
24 - try {
25 - const response = await fetch('/api/relay-info');
26 - if (response.ok) {
27 - const data = await response.json();
28 - if (data.relayUrl) {
29 - console.log('[SecureWebSocket] Got relay URL from server:', data.relayUrl);
30 - return data.relayUrl;
31 - }
32 - }
33 - } catch (error) {
34 - console.warn('[SecureWebSocket] Failed to fetch relay info from server:', error.message);
35 - }
36 -
37 - // 3. Auto-detect from current location
38 - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
39 - const host = window.location.host;
40 - const autoUrl = `${protocol}//${host}/relay`;
41 -
42 - console.log('[SecureWebSocket] Auto-detected relay URL:', autoUrl);
43 - return autoUrl;
44 -}
45 -
46 -/**
47 - * Check if Service Worker is available and has WebSocket support
48 - * @returns {Promise<boolean>}
49 - */
50 -async function hasServiceWorkerWebSocket() {
51 - if (!navigator.serviceWorker || !navigator.serviceWorker.controller) {
52 - return false;
53 - }
54 -
55 - try {
56 - const channel = new MessageChannel();
57 - const response = await new Promise((resolve) => {
58 - channel.port1.onmessage = (event) => resolve(event.data);
59 - navigator.serviceWorker.controller.postMessage(
60 - { type: 'GET_STATUS' },
61 - [channel.port2]
62 - );
63 - setTimeout(() => resolve({ success: false }), 1000);
64 - });
65 -
66 - return response.success && response.status?.hasWebSocket;
67 - } catch {
68 - return false;
69 - }
70 -}
71 -
72 -/**
73 - * Initialize and get the WASM ProxyEngine instance
74 - * @returns {Promise<Object>} WASM module with ProxyEngine
75 - */
76 -async function getProxyEngine() {
77 - if (wasmInstance) {
78 - return wasmInstance;
79 - }
80 -
81 - if (wasmInitPromise) {
82 - return wasmInitPromise;
83 - }
84 -
85 - wasmInitPromise = (async () => {
86 - try {
87 - // Check if we should use Service Worker
88 - const useServiceWorker = await hasServiceWorkerWebSocket();
89 -
90 - if (useServiceWorker) {
91 - console.log('[SecureWebSocket] Using Service Worker for WebSocket');
92 - wasmInstance = {
93 - engine: null,
94 - wasm: null,
95 - useServiceWorker: true
96 - };
97 - return wasmInstance;
98 - }
99 -
100 - // Fallback to direct WASM
101 - console.log('[SecureWebSocket] Using direct WASM');
102 -
103 - // Load WASM module
104 - if (typeof wasm_bindgen === 'undefined') {
105 - throw new Error('WASM module not loaded. Include portal_wasm.js first.');
106 - }
107 -
108 - // Initialize WASM
109 - await wasm_bindgen('/pkg/portal_wasm_bg.wasm');
110 -
111 - // Get relay server URL
112 - const relayUrl = await getRelayUrl();
113 -
114 - // Create ProxyEngine instance
115 - const engine = new wasm_bindgen.ProxyEngine(relayUrl);
116 -
117 - console.log('[SecureWebSocket] WASM ProxyEngine initialized:', relayUrl);
118 -
119 - wasmInstance = {
120 - engine,
121 - wasm: wasm_bindgen,
122 - useServiceWorker: false
123 - };
124 -
125 - return wasmInstance;
126 -
127 - } catch (error) {
128 - console.error('[SecureWebSocket] Failed to initialize WASM:', error);
129 - wasmInitPromise = null;
130 - throw error;
131 - }
132 - })();
133 -
134 - return wasmInitPromise;
135 -}
136 -
137 -/**
138 - * SecureWebSocket - Drop-in replacement for native WebSocket with E2EE
139 - */
140 -class SecureWebSocket extends EventTarget {
141 - /**
142 - * @param {string} url - WebSocket URL
143 - * @param {string|string[]} protocols - Optional subprotocols
144 - */
145 - constructor(url, protocols = []) {
146 - super();
147 -
148 - // Normalize protocols
149 - if (typeof protocols === 'string') {
150 - protocols = [protocols];
151 - }
152 -
153 - // Public properties (read-only)
154 - Object.defineProperties(this, {
155 - url: { value: url, writable: false, enumerable: true },
156 - protocols: { value: protocols, writable: false, enumerable: true },
157 - });
158 -
159 - // Internal state
160 - this._readyState = WebSocket.CONNECTING;
161 - this._protocol = '';
162 - this._tunnelId = null;
163 - this._bufferedAmount = 0;
164 - this._extensions = '';
165 - this._binaryType = 'blob';
166 -
167 - // Event handlers (nullable)
168 - this.onopen = null;
169 - this.onmessage = null;
170 - this.onerror = null;
171 - this.onclose = null;
172 -
173 - // Start connection
174 - this._connect();
175 - }
176 -
177 - // Public properties with getters
178 - get readyState() { return this._readyState; }
179 - get protocol() { return this._protocol; }
180 - get bufferedAmount() { return this._bufferedAmount; }
181 - get extensions() { return this._extensions; }
182 - get binaryType() { return this._binaryType; }
183 - set binaryType(value) {
184 - if (value === 'blob' || value === 'arraybuffer') {
185 - this._binaryType = value;
186 - }
187 - }
188 -
189 - /**
190 - * Initialize connection through WASM ProxyEngine
191 - * @private
192 - */
193 - async _connect() {
194 - try {
195 - console.log('[SecureWebSocket] Connecting to:', this.url);
196 -
197 - // Get WASM ProxyEngine or Service Worker
198 - const instance = await getProxyEngine();
199 -
200 - if (instance.useServiceWorker) {
201 - // Use Service Worker
202 - await this._connectViaServiceWorker();
203 - } else {
204 - // Use direct WASM
205 - await this._connectViaDirect(instance.engine);
206 - }
207 -
208 - } catch (error) {
209 - console.error('[SecureWebSocket] Connection failed:', error);
210 -
211 - this._readyState = WebSocket.CLOSED;
212 -
213 - // Dispatch error event
214 - this._dispatchEvent('error', {
215 - message: error.toString(),
216 - error: error
217 - });
218 -
219 - // Dispatch close event
220 - this._dispatchEvent('close', {
221 - code: 1006,
222 - reason: error.toString(),
223 - wasClean: false
224 - });
225 - }
226 - }
227 -
228 - /**
229 - * Connect via Service Worker
230 - * @private
231 - */
232 - async _connectViaServiceWorker() {
233 - console.log('[SecureWebSocket] Connecting via Service Worker');
234 -
235 - const channel = new MessageChannel();
236 - const response = await new Promise((resolve, reject) => {
237 - channel.port1.onmessage = (event) => {
238 - if (event.data.success) {
239 - resolve(event.data.result);
240 - } else {
241 - reject(new Error(event.data.error));
242 - }
243 - };
244 -
245 - navigator.serviceWorker.controller.postMessage(
246 - {
247 - type: 'WEBSOCKET_OPEN',
248 - url: this.url,
249 - protocols: this.protocols
250 - },
251 - [channel.port2]
252 - );
253 -
254 - setTimeout(() => reject(new Error('Service Worker timeout')), 10000);
255 - });
256 -
257 - this._tunnelId = response.tunnelId;
258 - this._protocol = response.protocol || '';
259 - this._readyState = WebSocket.OPEN;
260 - this._useServiceWorker = true;
261 -
262 - console.log('[SecureWebSocket] Connected via SW! Tunnel ID:', this._tunnelId);
263 -
264 - // Dispatch open event
265 - this._dispatchEvent('open', {});
266 -
267 - // Listen for messages from Service Worker
268 - this._listenToServiceWorker();
269 - }
270 -
271 - /**
272 - * Connect via direct WASM
273 - * @private
274 - */
275 - async _connectViaDirect(engine) {
276 - console.log('[SecureWebSocket] Connecting via direct WASM');
277 -
278 - // Open WebSocket tunnel through E2EE proxy
279 - const result = await engine.open_websocket(this.url, this.protocols);
280 -
281 - this._tunnelId = result.tunnelId;
282 - this._protocol = result.protocol || '';
283 - this._readyState = WebSocket.OPEN;
284 - this._useServiceWorker = false;
285 -
286 - console.log('[SecureWebSocket] Connected! Tunnel ID:', this._tunnelId);
287 -
288 - // Dispatch open event
289 - this._dispatchEvent('open', {});
290 -
291 - // Start receiving messages in background
292 - this._receiveLoop(engine);
293 - }
294 -
295 - /**
296 - * Listen to Service Worker messages
297 - * @private
298 - */
299 - _listenToServiceWorker() {
300 - const handler = (event) => {
301 - if (event.data.type === 'WEBSOCKET_MESSAGE' &&
302 - event.data.tunnelId === this._tunnelId) {
303 -
304 - const msg = event.data.message;
305 - this._handleMessage(msg);
306 - }
307 - };
308 -
309 - navigator.serviceWorker.addEventListener('message', handler);
310 - this._swMessageHandler = handler;
311 - }
312 -
313 - /**
314 - * Handle incoming message
315 - * @private
316 - */
317 - _handleMessage(msg) {
318 - if (msg.type === 'text') {
319 - // Text message
320 - this._dispatchEvent('message', {
321 - data: msg.data,
322 - type: 'message',
323 - origin: this.url
324 - });
325 -
326 - } else if (msg.type === 'binary') {
327 - // Binary message
328 - let data;
329 - if (this._binaryType === 'arraybuffer') {
330 - data = new Uint8Array(msg.data).buffer;
331 - } else {
332 - data = new Blob([new Uint8Array(msg.data)]);
333 - }
334 -
335 - this._dispatchEvent('message', {
336 - data: data,
337 - type: 'message',
338 - origin: this.url
339 - });
340 -
341 - } else if (msg.type === 'close') {
342 - // Close message
343 - console.log('[SecureWebSocket] Received close:', msg.code, msg.reason);
344 -
345 - this._readyState = WebSocket.CLOSED;
346 -
347 - this._dispatchEvent('close', {
348 - code: msg.code || 1000,
349 - reason: msg.reason || '',
350 - wasClean: true
351 - });
352 -
353 - // Cleanup Service Worker listener
354 - if (this._swMessageHandler) {
355 - navigator.serviceWorker.removeEventListener('message', this._swMessageHandler);
356 - }
357 - }
358 - }
359 -
360 - /**
361 - * Background loop to receive messages from tunnel
362 - * @private
363 - * @param {Object} engine - WASM ProxyEngine instance
364 - */
365 - async _receiveLoop(engine) {
366 - try {
367 - while (this._readyState !== WebSocket.CLOSED && this._readyState !== WebSocket.CLOSING) {
368 - // Receive message from tunnel
369 - const msg = await engine.receive_websocket_message(this._tunnelId);
370 -
371 - if (msg.type === 'text') {
372 - // Text message
373 - this._dispatchEvent('message', {
374 - data: msg.data,
375 - type: 'message',
376 - origin: this.url
377 - });
378 -
379 - } else if (msg.type === 'binary') {
380 - // Binary message
381 - let data;
382 - if (this._binaryType === 'arraybuffer') {
383 - data = new Uint8Array(msg.data).buffer;
384 - } else {
385 - // Convert to Blob
386 - data = new Blob([new Uint8Array(msg.data)]);
387 - }
388 -
389 - this._dispatchEvent('message', {
390 - data: data,
391 - type: 'message',
392 - origin: this.url
393 - });
394 -
395 - } else if (msg.type === 'close') {
396 - // Close message
397 - console.log('[SecureWebSocket] Received close:', msg.code, msg.reason);
398 -
399 - this._readyState = WebSocket.CLOSED;
400 -
401 - this._dispatchEvent('close', {
402 - code: msg.code || 1000,
403 - reason: msg.reason || '',
404 - wasClean: true
405 - });
406 -
407 - break;
408 - }
409 - }
410 -
411 - } catch (error) {
412 - console.error('[SecureWebSocket] Receive loop error:', error);
413 -
414 - if (this._readyState !== WebSocket.CLOSED) {
415 - this._dispatchEvent('error', {
416 - message: error.toString(),
417 - error: error
418 - });
419 -
420 - this.close(1006, error.toString());
421 - }
422 - }
423 - }
424 -
425 - /**
426 - * Send data through the secure tunnel
427 - * @param {string|ArrayBuffer|Uint8Array|Blob} data - Data to send
428 - */
429 - send(data) {
430 - if (this._readyState !== WebSocket.OPEN) {
431 - throw new DOMException(
432 - 'Failed to execute \'send\' on \'WebSocket\': Still in CONNECTING state.',
433 - 'InvalidStateError'
434 - );
435 - }
436 -
437 - // Handle different data types
438 - if (typeof data === 'string') {
439 - // Text message
440 - this._sendMessage(data, false);
441 -
442 - } else if (data instanceof ArrayBuffer) {
443 - // Binary ArrayBuffer
444 - this._sendMessage(new Uint8Array(data), true);
445 -
446 - } else if (data instanceof Uint8Array) {
447 - // Binary Uint8Array
448 - this._sendMessage(data, true);
449 -
450 - } else if (data instanceof Blob) {
451 - // Blob - convert to ArrayBuffer
452 - this._bufferedAmount += data.size;
453 -
454 - data.arrayBuffer().then(buffer => {
455 - this._sendMessage(new Uint8Array(buffer), true);
456 - this._bufferedAmount = Math.max(0, this._bufferedAmount - data.size);
457 - });
458 -
459 - } else {
460 - throw new TypeError('Data must be string, ArrayBuffer, Uint8Array, or Blob');
461 - }
462 - }
463 -
464 - /**
465 - * Send message through WASM ProxyEngine
466 - * @private
467 - * @param {string|Uint8Array} data - Data to send
468 - * @param {boolean} isBinary - Whether data is binary
469 - */
470 - async _sendMessage(data, isBinary) {
471 - try {
472 - // Estimate buffer size
473 - const size = typeof data === 'string' ? data.length : data.length;
474 - this._bufferedAmount += size;
475 -
476 - if (this._useServiceWorker) {
477 - // Send via Service Worker
478 - const channel = new MessageChannel();
479 - await new Promise((resolve, reject) => {
480 - channel.port1.onmessage = (event) => {
481 - if (event.data.success) {
482 - resolve();
483 - } else {
484 - reject(new Error(event.data.error));
485 - }
486 - };
487 -
488 - navigator.serviceWorker.controller.postMessage(
489 - {
490 - type: 'WEBSOCKET_SEND',
491 - tunnelId: this._tunnelId,
492 - data: data,
493 - isBinary: isBinary
494 - },
495 - [channel.port2]
496 - );
497 -
498 - setTimeout(() => reject(new Error('Send timeout')), 5000);
499 - });
500 - } else {
501 - // Send via direct WASM
502 - const { engine } = await getProxyEngine();
503 - await engine.send_websocket_message(this._tunnelId, data, isBinary);
504 - }
505 -
506 - // Decrement buffered amount
507 - this._bufferedAmount = Math.max(0, this._bufferedAmount - size);
508 -
509 - } catch (error) {
510 - console.error('[SecureWebSocket] Send failed:', error);
511 -
512 - this._dispatchEvent('error', {
513 - message: error.toString(),
514 - error: error
515 - });
516 - }
517 - }
518 -
519 - /**
520 - * Close the WebSocket connection
521 - * @param {number} code - Close code (default 1000)
522 - * @param {string} reason - Close reason (default empty)
523 - */
524 - close(code = 1000, reason = '') {
525 - if (this._readyState === WebSocket.CLOSED || this._readyState === WebSocket.CLOSING) {
526 - return;
527 - }
528 -
529 - console.log('[SecureWebSocket] Closing:', code, reason);
530 -
531 - this._readyState = WebSocket.CLOSING;
532 -
533 - // Close tunnel
534 - (async () => {
535 - try {
536 - if (this._useServiceWorker) {
537 - // Close via Service Worker
538 - const channel = new MessageChannel();
539 - await new Promise((resolve, reject) => {
540 - channel.port1.onmessage = (event) => {
541 - if (event.data.success) {
542 - resolve();
543 - } else {
544 - reject(new Error(event.data.error));
545 - }
546 - };
547 -
548 - navigator.serviceWorker.controller.postMessage(
549 - {
550 - type: 'WEBSOCKET_CLOSE',
551 - tunnelId: this._tunnelId,
552 - code: code,
553 - reason: reason
554 - },
555 - [channel.port2]
556 - );
557 -
558 - setTimeout(() => resolve(), 2000); // Don't wait forever
559 - });
560 - } else {
561 - // Close via direct WASM
562 - const { engine } = await getProxyEngine();
563 - await engine.close_websocket(this._tunnelId, code, reason);
564 - }
565 - } catch (error) {
566 - console.error('[SecureWebSocket] Close failed:', error);
567 -
568 - // Force close
569 - this._readyState = WebSocket.CLOSED;
570 - this._dispatchEvent('close', {
571 - code: 1006,
572 - reason: error.toString(),
573 - wasClean: false
574 - });
575 - }
576 - })();
577 - }
578 -
579 - /**
580 - * Dispatch event to both EventTarget and legacy handler
581 - * @private
582 - * @param {string} type - Event type
583 - * @param {Object} detail - Event details
584 - */
585 - _dispatchEvent(type, detail) {
586 - // Create event
587 - const event = new Event(type);
588 - Object.assign(event, detail);
589 -
590 - // Dispatch to EventTarget listeners
591 - this.dispatchEvent(event);
592 -
593 - // Call legacy handler if exists
594 - const handler = this[`on${type}`];
595 - if (typeof handler === 'function') {
596 - try {
597 - handler.call(this, event);
598 - } catch (error) {
599 - console.error(`[SecureWebSocket] Error in on${type} handler:`, error);
600 - }
601 - }
602 - }
603 -}
604 -
605 -// Static constants (same as native WebSocket)
606 -SecureWebSocket.CONNECTING = 0;
607 -SecureWebSocket.OPEN = 1;
608 -SecureWebSocket.CLOSING = 2;
609 -SecureWebSocket.CLOSED = 3;
610 -
611 -// ==============================================================================
612 -// POLYFILL: Replace native WebSocket with SecureWebSocket
613 -// ==============================================================================
614 -
615 -(function() {
616 - // Save reference to native WebSocket
617 - const NativeWebSocket = window.WebSocket;
618 -
619 - // Configuration
620 - const config = {
621 - // Enable E2EE for all WebSockets by default
622 - enabled: window.PORTAL_E2EE_ENABLED !== false,
623 -
624 - // Patterns to intercept (regex strings)
625 - interceptPatterns: window.PORTAL_INTERCEPT_PATTERNS || [
626 - '.*' // Intercept all by default
627 - ],
628 -
629 - // Patterns to bypass (regex strings) - takes precedence
630 - bypassPatterns: window.PORTAL_BYPASS_PATTERNS || [
631 - '^wss?://localhost:4017/', // Don't intercept relay server itself
632 - '^wss?://localhost:8000/', // Don't intercept local dev server
633 - '^wss?://127\\.0\\.0\\.1', // Don't intercept loopback
634 - ],
635 -
636 - // Debug mode
637 - debug: window.PORTAL_DEBUG || false
638 - };
639 -
640 - /**
641 - * Check if URL should be intercepted for E2EE
642 - * @param {string} url - WebSocket URL
643 - * @returns {boolean} True if should intercept
644 - */
645 - function shouldIntercept(url) {
646 - if (!config.enabled) {
647 - return false;
648 - }
649 -
650 - // Check bypass patterns first (higher priority)
651 - for (const pattern of config.bypassPatterns) {
652 - const regex = new RegExp(pattern);
653 - if (regex.test(url)) {
654 - if (config.debug) {
655 - console.log('[SecureWebSocket] Bypassing (matched bypass pattern):', url);
656 - }
657 - return false;
658 - }
659 - }
660 -
661 - // Check intercept patterns
662 - for (const pattern of config.interceptPatterns) {
663 - const regex = new RegExp(pattern);
664 - if (regex.test(url)) {
665 - if (config.debug) {
666 - console.log('[SecureWebSocket] Intercepting (matched intercept pattern):', url);
667 - }
668 - return true;
669 - }
670 - }
671 -
672 - if (config.debug) {
673 - console.log('[SecureWebSocket] Not intercepting (no match):', url);
674 - }
675 - return false;
676 - }
677 -
678 - /**
679 - * Polyfilled WebSocket constructor
680 - * @param {string} url - WebSocket URL
681 - * @param {string|string[]} protocols - Optional subprotocols
682 - * @returns {WebSocket|SecureWebSocket}
683 - */
684 - window.WebSocket = function(url, protocols) {
685 - if (shouldIntercept(url)) {
686 - // Use E2EE SecureWebSocket
687 - console.log('[SecureWebSocket] 🔒 Creating encrypted WebSocket:', url);
688 - return new SecureWebSocket(url, protocols);
689 - } else {
690 - // Use native WebSocket
691 - if (config.debug) {
692 - console.log('[SecureWebSocket] Creating native WebSocket:', url);
693 - }
694 - return new NativeWebSocket(url, protocols);
695 - }
696 - };
697 -
698 - // Copy static properties from native WebSocket
699 - window.WebSocket.CONNECTING = NativeWebSocket.CONNECTING;
700 - window.WebSocket.OPEN = NativeWebSocket.OPEN;
701 - window.WebSocket.CLOSING = NativeWebSocket.CLOSING;
702 - window.WebSocket.CLOSED = NativeWebSocket.CLOSED;
703 -
704 - // Expose SecureWebSocket class for direct access if needed
705 - window.SecureWebSocket = SecureWebSocket;
706 - window.NativeWebSocket = NativeWebSocket;
707 -
708 - console.log('[SecureWebSocket] ✅ Polyfill installed. E2EE enabled:', config.enabled);
709 -
710 -})();
cmd/relay-server/wasm/sw-proxy.js deleted
-285
@@ -1,285 +0,0 @@
1 -// Service Worker for Portal Network Proxy
2 -// WASM must be loaded during install phase
3 -
4 -const CACHE_NAME = 'portal-proxy-v1';
5 -let proxyEngine = null;
6 -let wasmReady = false;
7 -let initializationPromise = null;
8 -
9 -// Dynamic WASM initialization (can be called anytime, not just install)
10 -async function initializeProxyEngine() {
11 - // If already initializing, wait for it
12 - if (initializationPromise) {
13 - return initializationPromise;
14 - }
15 -
16 - // If already initialized, return immediately
17 - if (proxyEngine && wasmReady) {
18 - return proxyEngine;
19 - }
20 -
21 - initializationPromise = (async () => {
22 - try {
23 - console.log('[SW-Proxy] Dynamically initializing ProxyEngine...');
24 -
25 - // Check if wasm_bindgen is available (loaded during install)
26 - if (typeof wasm_bindgen === 'undefined') {
27 - console.log('[SW-Proxy] wasm_bindgen not available, loading via dynamic import...');
28 -
29 - // Use dynamic import for ES6 modules
30 - const wasmModule = await import('/pkg/portal_wasm.js');
31 -
32 - // Make wasm_bindgen available globally
33 - self.wasm_bindgen = wasmModule;
34 -
35 - console.log('[SW-Proxy] ✓ WASM JS module loaded');
36 - }
37 -
38 - // Check if SecureWebSocket SW module is loaded
39 - if (typeof ServiceWorkerWebSocketTunnel === 'undefined') {
40 - console.log('[SW-Proxy] SecureWebSocket SW module not available, loading via fetch...');
41 -
42 - const swWsResponse = await fetch('/secure-websocket-sw.js');
43 - const swWsCode = await swWsResponse.text();
44 -
45 - // Use indirect eval to execute in global scope
46 - (1, eval)(swWsCode);
47 - console.log('[SW-Proxy] ✓ SecureWebSocket SW module loaded');
48 - }
49 -
50 - // Initialize WASM module if not already done
51 - if (!wasmReady) {
52 - console.log('[SW-Proxy] Initializing WASM module...');
53 -
54 - // wasm_bindgen is now the module object from dynamic import
55 - const initWasm = self.wasm_bindgen.default || self.wasm_bindgen;
56 - await initWasm('/pkg/portal_wasm_bg.wasm');
57 -
58 - console.log('[SW-Proxy] ✓ WASM module initialized');
59 - }
60 -
61 - // Get relay URL from API
62 - let relayUrl = 'ws://localhost:4017/relay'; // Default fallback
63 - try {
64 - const relayInfoResponse = await fetch('/api/relay-info');
65 - if (relayInfoResponse.ok) {
66 - const relayInfo = await relayInfoResponse.json();
67 - if (relayInfo.relayUrl) {
68 - relayUrl = relayInfo.relayUrl;
69 - console.log('[SW-Proxy] Got relay URL from server:', relayUrl);
70 - }
71 - }
72 - } catch (e) {
73 - console.warn('[SW-Proxy] Failed to fetch relay URL, using default:', e.message);
74 - }
75 -
76 - // Create ProxyEngine
77 - console.log('[SW-Proxy] Creating ProxyEngine with URL:', relayUrl);
78 - const ProxyEngine = self.wasm_bindgen.ProxyEngine;
79 - proxyEngine = new ProxyEngine(relayUrl);
80 - wasmReady = true;
81 -
82 - console.log('[SW-Proxy] ✓ ProxyEngine ready');
83 - return proxyEngine;
84 -
85 - } catch (error) {
86 - console.error('[SW-Proxy] Failed to initialize ProxyEngine:', error);
87 - initializationPromise = null; // Reset so we can retry
88 - wasmReady = false;
89 - throw error;
90 - }
91 - })();
92 -
93 - return initializationPromise;
94 -}
95 -
96 -// Install event - Skip importScripts since WASM is ES6 module
97 -self.addEventListener('install', (event) => {
98 - console.log('[SW-Proxy] Installing...');
99 -
100 - event.waitUntil(
101 - (async () => {
102 - try {
103 - console.log('[SW-Proxy] Service Worker installing...');
104 -
105 - // Note: We cannot use importScripts with ES6 modules
106 - // WASM will be loaded dynamically on first request via fetch + dynamic import
107 - console.log('[SW-Proxy] WASM will be loaded dynamically on first request');
108 -
109 - } catch (error) {
110 - console.error('[SW-Proxy] Install error:', error);
111 - }
112 -
113 - // Skip waiting to activate immediately
114 - await self.skipWaiting();
115 - })()
116 - );
117 -});
118 -
119 -// Activate event
120 -self.addEventListener('activate', (event) => {
121 - console.log('[SW-Proxy] Activating...');
122 - event.waitUntil(self.clients.claim());
123 -});
124 -
125 -// Check if URL should be proxied
126 -function shouldProxy(url) {
127 - // Don't proxy same-origin requests (relay server itself)
128 - if (url.includes('localhost:4017') ||
129 - url.includes('localhost:8000') ||
130 - url.includes('/pkg/') ||
131 - url.includes('/sw-proxy.js') ||
132 - url.includes('/api/')) {
133 - return false;
134 - }
135 -
136 - // Only proxy /peer/* requests
137 - return url.includes('/peer/');
138 -}
139 -
140 -// Handle HTTP request through WASM proxy
141 -async function proxyHttpRequest(request) {
142 - try {
143 - // Ensure ProxyEngine is initialized (lazy init if needed)
144 - if (!wasmReady || !proxyEngine) {
145 - console.log('[SW-Proxy] ProxyEngine not ready, attempting lazy initialization...');
146 - try {
147 - await initializeProxyEngine();
148 - } catch (initError) {
149 - // Security: Never fallback to direct fetch - this would bypass E2EE!
150 - console.error('[SW-Proxy] Failed to initialize ProxyEngine:', initError);
151 - return new Response(
152 - JSON.stringify({
153 - error: 'E2EE ProxyEngine Initialization Failed',
154 - message: 'Could not initialize secure proxy. Please refresh the page.',
155 - code: 'PROXY_ENGINE_INIT_FAILED',
156 - details: initError.message
157 - }),
158 - {
159 - status: 503,
160 - statusText: 'Service Unavailable',
161 - headers: {
162 - 'Content-Type': 'application/json',
163 - 'X-E2EE-Status': 'init-failed'
164 - }
165 - }
166 - );
167 - }
168 - }
169 -
170 - console.log('[SW-Proxy] Proxying:', request.method, request.url);
171 -
172 - // Extract headers
173 - const headers = {};
174 - for (const [key, value] of request.headers.entries()) {
175 - headers[key] = value;
176 - }
177 -
178 - // Get body if present
179 - let body = null;
180 - if (request.method !== 'GET' && request.method !== 'HEAD') {
181 - try {
182 - const arrayBuffer = await request.arrayBuffer();
183 - body = Array.from(new Uint8Array(arrayBuffer));
184 - } catch (e) {
185 - console.warn('[SW-Proxy] Failed to read body:', e);
186 - }
187 - }
188 -
189 - // Call WASM ProxyEngine
190 - console.log('[SW-Proxy] Calling WASM ProxyEngine...');
191 - const response = await proxyEngine.handleHttpRequest(
192 - request.method,
193 - request.url,
194 - headers,
195 - body
196 - );
197 -
198 - console.log('[SW-Proxy] Got response:', response.status);
199 -
200 - // Reconstruct Response object
201 - const responseHeaders = new Headers();
202 - for (const [key, value] of Object.entries(response.headers || {})) {
203 - responseHeaders.set(key, value);
204 - }
205 -
206 - return new Response(response.body, {
207 - status: response.status,
208 - statusText: response.statusText || 'OK',
209 - headers: responseHeaders
210 - });
211 -
212 - } catch (error) {
213 - console.error('[SW-Proxy] Proxy error:', error);
214 - // Security: Never fallback to direct fetch - return error instead
215 - return new Response(
216 - JSON.stringify({
217 - error: 'E2EE Proxy Error',
218 - message: error.message || 'Failed to proxy request through E2EE tunnel',
219 - code: 'PROXY_ERROR'
220 - }),
221 - {
222 - status: 502,
223 - statusText: 'Bad Gateway',
224 - headers: {
225 - 'Content-Type': 'application/json',
226 - 'X-E2EE-Status': 'error'
227 - }
228 - }
229 - );
230 - }
231 -}
232 -
233 -// Fetch event - main interception point
234 -self.addEventListener('fetch', (event) => {
235 - const url = event.request.url;
236 -
237 - // Check if request should be proxied
238 - if (shouldProxy(url)) {
239 - console.log('[SW-Proxy] Intercepting:', url);
240 - event.respondWith(proxyHttpRequest(event.request));
241 - } else {
242 - // Pass through directly
243 - event.respondWith(fetch(event.request));
244 - }
245 -});
246 -
247 -// Message handler
248 -self.addEventListener('message', (event) => {
249 - const { type } = event.data || {};
250 -
251 - // Try WebSocket handler first
252 - if (typeof handleWebSocketMessage === 'function') {
253 - const handled = handleWebSocketMessage(event);
254 - if (handled instanceof Promise) {
255 - // Async handler
256 - return;
257 - } else if (handled) {
258 - // Synchronously handled
259 - return;
260 - }
261 - }
262 -
263 - // Standard message handling
264 - switch (type) {
265 - case 'GET_STATUS':
266 - event.ports[0]?.postMessage({
267 - success: true,
268 - status: {
269 - wasmReady,
270 - hasEngine: !!proxyEngine,
271 - hasWebSocket: typeof handleWebSocketMessage === 'function'
272 - }
273 - });
274 - break;
275 -
276 - case 'PING':
277 - event.ports[0]?.postMessage({ type: 'PONG', wasmReady });
278 - break;
279 -
280 - default:
281 - console.warn('[SW-Proxy] Unknown message:', type);
282 - }
283 -});
284 -
285 -console.log('[SW-Proxy] Service Worker script loaded');
cmd/relay-server/wasm/sw.js deleted
-120
@@ -1,120 +0,0 @@
1 -// Service Worker for Portal WASM Client
2 -const CACHE_NAME = 'portal-wasm-v1';
3 -
4 -// Files to cache
5 -const urlsToCache = [
6 - '/pkg/portal_wasm.js',
7 - '/pkg/portal_wasm_bg.wasm',
8 - '/example.html',
9 - '/adapter-test.html'
10 -];
11 -
12 -// Install event - cache files
13 -self.addEventListener('install', (event) => {
14 - console.log('[SW] Installing Service Worker...');
15 - event.waitUntil(
16 - caches.open(CACHE_NAME)
17 - .then((cache) => {
18 - console.log('[SW] Caching WASM files');
19 - return cache.addAll(urlsToCache);
20 - })
21 - .then(() => {
22 - console.log('[SW] All files cached successfully');
23 - return self.skipWaiting(); // Activate immediately
24 - })
25 - );
26 -});
27 -
28 -// Activate event - clean up old caches
29 -self.addEventListener('activate', (event) => {
30 - console.log('[SW] Activating Service Worker...');
31 - event.waitUntil(
32 - caches.keys().then((cacheNames) => {
33 - return Promise.all(
34 - cacheNames.map((cacheName) => {
35 - if (cacheName !== CACHE_NAME) {
36 - console.log('[SW] Deleting old cache:', cacheName);
37 - return caches.delete(cacheName);
38 - }
39 - })
40 - );
41 - }).then(() => {
42 - console.log('[SW] Service Worker activated');
43 - return self.clients.claim(); // Take control immediately
44 - })
45 - );
46 -});
47 -
48 -// Fetch event - serve from cache or network
49 -self.addEventListener('fetch', (event) => {
50 - const url = new URL(event.request.url);
51 -
52 - // Handle WASM files with special headers
53 - if (url.pathname.endsWith('.wasm')) {
54 - event.respondWith(
55 - caches.match(event.request)
56 - .then((response) => {
57 - if (response) {
58 - console.log('[SW] Serving WASM from cache:', url.pathname);
59 - return response;
60 - }
61 -
62 - console.log('[SW] Fetching WASM from network:', url.pathname);
63 - return fetch(event.request)
64 - .then((networkResponse) => {
65 - // Clone the response
66 - const responseToCache = networkResponse.clone();
67 -
68 - // Cache the fetched response
69 - caches.open(CACHE_NAME)
70 - .then((cache) => {
71 - cache.put(event.request, responseToCache);
72 - });
73 -
74 - return networkResponse;
75 - });
76 - })
77 - );
78 - }
79 - // Handle JS files
80 - else if (url.pathname.endsWith('portal_wasm.js')) {
81 - event.respondWith(
82 - caches.match(event.request)
83 - .then((response) => {
84 - if (response) {
85 - console.log('[SW] Serving JS from cache:', url.pathname);
86 - return response;
87 - }
88 -
89 - return fetch(event.request)
90 - .then((networkResponse) => {
91 - const responseToCache = networkResponse.clone();
92 - caches.open(CACHE_NAME)
93 - .then((cache) => {
94 - cache.put(event.request, responseToCache);
95 - });
96 - return networkResponse;
97 - });
98 - })
99 - );
100 - }
101 - // All other requests - network first, fallback to cache
102 - else {
103 - event.respondWith(
104 - fetch(event.request)
105 - .catch(() => {
106 - return caches.match(event.request);
107 - })
108 - );
109 - }
110 -});
111 -
112 -// Message handler
113 -self.addEventListener('message', (event) => {
114 - if (event.data && event.data.type === 'SKIP_WAITING') {
115 - console.log('[SW] Received SKIP_WAITING message');
116 - self.skipWaiting();
117 - }
118 -});
119 -
120 -console.log('[SW] Service Worker loaded');