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');