feat(relay): add server-side reverse proxy for same-origin cookie support
Hee Sung Son committed
Feb 20, 2026 at 21:47 UTC
de9a9d211a27c7e9bfa0b3f9258646a9660544c6
8 files changed
+356
-74
cmd/demo-app/main.go
+12
-6
@@ -136,13 +136,19 @@ func runDemo() error {
136
mux.HandleFunc("/ws", handleWS)
137
138
// Test endpoint for multiple Set-Cookie headers
139
+ // Note: HttpOnly cookies cannot be set via Service Worker (browser security limitation)
140
mux.HandleFunc("/api/test-cookies", func(w http.ResponseWriter, r *http.Request) {
141
http.SetCookie(w, &http.Cookie{
141
- Name: "session_id",
142
- Value: "abc123",
143
- Path: "/",
144
- HttpOnly: true,
145
- MaxAge: 3600,
142
+ Name: "session_id",
143
+ Value: "abc123",
144
+ Path: "/",
145
+ MaxAge: 3600,
146
+ })
147
+ http.SetCookie(w, &http.Cookie{
148
+ Name: "auth_token",
149
+ Value: "secret456",
150
+ Path: "/",
151
+ MaxAge: 3600,
152
})
153
http.SetCookie(w, &http.Cookie{
154
Name: "csrf_token",
@@ -158,7 +164,7 @@ func runDemo() error {
164
})
165
w.Header().Set("Content-Type", "application/json")
166
json.NewEncoder(w).Encode(map[string]any{
161
- "message": "3 cookies set: session_id, csrf_token, user_pref",
167
+ "message": "4 cookies set: session_id, auth_token, csrf_token, user_pref",
168
})
169
})
170
cmd/relay-server/proxy.go
new
+223
@@ -0,0 +1,223 @@
1
+package main
2
+
3
+import (
4
+ "bufio"
5
+ "context"
6
+ "io"
7
+ "net"
8
+ "net/http"
9
+ "net/http/httputil"
10
+ "net/url"
11
+ "strings"
12
+ "time"
13
+
14
+ "github.com/rs/zerolog/log"
15
+ "golang.org/x/net/idna"
16
+ "gosuda.org/portal/portal"
17
+ "gosuda.org/portal/utils"
18
+)
19
+
20
+type contextKey string
21
+
22
+const leaseIDContextKey contextKey = "leaseID"
23
+
24
+// HTTPProxy is a server-side HTTP reverse proxy that tunnels requests
25
+// to backend apps connected via portal tunnel. This makes all traffic
26
+// same-origin, enabling native Set-Cookie header support.
27
+type HTTPProxy struct {
28
+ server *portal.RelayServer
29
+ reverseProxy *httputil.ReverseProxy
30
+}
31
+
32
+// NewHTTPProxy creates a new HTTP reverse proxy for subdomain tunneling.
33
+func NewHTTPProxy(server *portal.RelayServer) *HTTPProxy {
34
+ p := &HTTPProxy{server: server}
35
+
36
+ transport := &http.Transport{
37
+ MaxIdleConns: 100,
38
+ MaxIdleConnsPerHost: 10,
39
+ IdleConnTimeout: 90 * time.Second,
40
+ DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
41
+ // addr is "leaseID:80" from the rewritten URL
42
+ host, _, err := net.SplitHostPort(addr)
43
+ if err != nil {
44
+ host = addr
45
+ }
46
+ return server.DialLease(host, "http/1.1")
47
+ },
48
+ }
49
+
50
+ p.reverseProxy = &httputil.ReverseProxy{
51
+ Rewrite: func(pr *httputil.ProxyRequest) {
52
+ leaseID := pr.In.Context().Value(leaseIDContextKey).(string)
53
+ pr.SetURL(&url.URL{
54
+ Scheme: "http",
55
+ Host: leaseID,
56
+ })
57
+ pr.SetXForwarded()
58
+ },
59
+ Transport: transport,
60
+ FlushInterval: -1, // stream responses immediately
61
+ ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
62
+ log.Error().Err(err).
63
+ Str("path", r.URL.Path).
64
+ Str("host", r.Host).
65
+ Msg("[HTTPProxy] reverse proxy error")
66
+ http.Error(w, "Bad Gateway", http.StatusBadGateway)
67
+ },
68
+ }
69
+
70
+ return p
71
+}
72
+
73
+// extractLeaseName extracts the lease name from the subdomain of the Host header.
74
+// Example: "demo-app.portal.example.com:4017" -> "demo-app"
75
+// Handles punycode/IDN domains.
76
+func extractLeaseName(host string) string {
77
+ h := strings.ToLower(utils.StripPort(utils.StripScheme(host)))
78
+ p := strings.ToLower(utils.StripPort(utils.StripScheme(flagPortalAppURL)))
79
+
80
+ if strings.HasPrefix(p, "*.") {
81
+ suffix := p[1:] // ".example.com"
82
+ if len(h) > len(suffix) && strings.HasSuffix(h, suffix) {
83
+ name := h[:len(h)-len(suffix)]
84
+ // Handle URL-encoded characters
85
+ if decoded, err := url.QueryUnescape(name); err == nil {
86
+ name = decoded
87
+ }
88
+ // Handle punycode/IDN
89
+ if unicode, err := idna.ToUnicode(name); err == nil {
90
+ name = unicode
91
+ }
92
+ return name
93
+ }
94
+ }
95
+
96
+ // Handle non-wildcard patterns (e.g., "sub.example.com" as base)
97
+ if len(h) > len(p)+1 && strings.HasSuffix(h, "."+p) {
98
+ name := h[:len(h)-len(p)-1]
99
+ if decoded, err := url.QueryUnescape(name); err == nil {
100
+ name = decoded
101
+ }
102
+ if unicode, err := idna.ToUnicode(name); err == nil {
103
+ name = unicode
104
+ }
105
+ return name
106
+ }
107
+
108
+ return ""
109
+}
110
+
111
+// resolveLease resolves a lease name to a lease ID using case-insensitive matching.
112
+func (p *HTTPProxy) resolveLease(name string) (string, bool) {
113
+ entry, ok := p.server.GetLeaseByNameFold(name)
114
+ if !ok {
115
+ return "", false
116
+ }
117
+ return entry.Lease.Identity.Id, true
118
+}
119
+
120
+// isWebSocketUpgrade checks if the request is a WebSocket upgrade request.
121
+func isWebSocketUpgrade(r *http.Request) bool {
122
+ return strings.EqualFold(r.Header.Get("Upgrade"), "websocket") &&
123
+ strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade")
124
+}
125
+
126
+// TryProxy attempts to reverse-proxy the request to a tunnel backend.
127
+// Returns true if the request was handled (proxied or WebSocket), false if
128
+// no matching lease was found (caller should fall back to portal HTML).
129
+func (p *HTTPProxy) TryProxy(w http.ResponseWriter, r *http.Request) bool {
130
+ leaseName := extractLeaseName(r.Host)
131
+ if leaseName == "" {
132
+ return false
133
+ }
134
+
135
+ leaseID, ok := p.resolveLease(leaseName)
136
+ if !ok {
137
+ return false
138
+ }
139
+
140
+ if isWebSocketUpgrade(r) {
141
+ p.handleWebSocket(w, r, leaseID)
142
+ return true
143
+ }
144
+
145
+ // HTTP reverse proxy with lease ID in context
146
+ ctx := context.WithValue(r.Context(), leaseIDContextKey, leaseID)
147
+ p.reverseProxy.ServeHTTP(w, r.WithContext(ctx))
148
+ return true
149
+}
150
+
151
+// handleWebSocket proxies a WebSocket upgrade request through the tunnel.
152
+func (p *HTTPProxy) handleWebSocket(w http.ResponseWriter, r *http.Request, leaseID string) {
153
+ // 1. Dial backend through tunnel
154
+ backendConn, err := p.server.DialLease(leaseID, "http/1.1")
155
+ if err != nil {
156
+ log.Error().Err(err).Str("lease_id", leaseID).Msg("[HTTPProxy] WebSocket: failed to dial lease")
157
+ http.Error(w, "Bad Gateway", http.StatusBadGateway)
158
+ return
159
+ }
160
+
161
+ // 2. Hijack client's TCP connection
162
+ hijacker, ok := w.(http.Hijacker)
163
+ if !ok {
164
+ log.Error().Msg("[HTTPProxy] WebSocket: response writer does not support hijacking")
165
+ backendConn.Close()
166
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
167
+ return
168
+ }
169
+
170
+ clientConn, _, err := hijacker.Hijack()
171
+ if err != nil {
172
+ log.Error().Err(err).Msg("[HTTPProxy] WebSocket: failed to hijack connection")
173
+ backendConn.Close()
174
+ return
175
+ }
176
+
177
+ // 3. Write the original upgrade request to backend
178
+ if err := r.Write(backendConn); err != nil {
179
+ log.Error().Err(err).Msg("[HTTPProxy] WebSocket: failed to write upgrade request to backend")
180
+ clientConn.Close()
181
+ backendConn.Close()
182
+ return
183
+ }
184
+
185
+ // 4. Read backend response and forward to client
186
+ backendBuf := bufio.NewReader(backendConn)
187
+ resp, err := http.ReadResponse(backendBuf, r)
188
+ if err != nil {
189
+ log.Error().Err(err).Msg("[HTTPProxy] WebSocket: failed to read backend response")
190
+ clientConn.Close()
191
+ backendConn.Close()
192
+ return
193
+ }
194
+
195
+ if err := resp.Write(clientConn); err != nil {
196
+ log.Error().Err(err).Msg("[HTTPProxy] WebSocket: failed to write response to client")
197
+ clientConn.Close()
198
+ backendConn.Close()
199
+ return
200
+ }
201
+
202
+ if resp.StatusCode != http.StatusSwitchingProtocols {
203
+ clientConn.Close()
204
+ backendConn.Close()
205
+ return
206
+ }
207
+
208
+ // 5. Bidirectional relay
209
+ errc := make(chan error, 2)
210
+ go func() {
211
+ _, err := io.Copy(backendConn, clientConn)
212
+ errc <- err
213
+ }()
214
+ go func() {
215
+ // Use backendBuf to drain any data buffered during ReadResponse
216
+ _, err := io.Copy(clientConn, backendBuf)
217
+ errc <- err
218
+ }()
219
+
220
+ <-errc
221
+ clientConn.Close()
222
+ backendConn.Close()
223
+}
cmd/relay-server/serve.go
+17
-7
@@ -137,15 +137,25 @@ func serveHTTP(addr string, serv *portal.RelayServer, admin *Admin, frontend *Fr
137
frontend.ServeDynamicServiceWorker(w, r)
138
})
139
140
- // Root and SPA fallback for portal subdomains
141
- portalMux.HandleFunc("/", withCORSMiddleware(func(w http.ResponseWriter, r *http.Request) {
142
- if r.URL.Path == "/" {
143
- // Serve portal HTML with SSR for OG metadata
144
- frontend.ServePortalHTMLWithSSR(w, r, serv)
140
+ // Create HTTP reverse proxy for subdomain tunneling (same-origin cookie support)
141
+ httpProxy := NewHTTPProxy(serv)
142
+
143
+ // Root handler: try server-side reverse proxy first, then fall back to portal HTML
144
+ portalMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
145
+ // Try server-side reverse proxy to tunnel backend
146
+ if httpProxy.TryProxy(w, r) {
147
return
148
}
147
- frontend.ServePortalStatic(w, r)
148
- }))
149
+
150
+ // Fallback: serve portal frontend (Service Worker based proxy)
151
+ withCORSMiddleware(func(w http.ResponseWriter, r *http.Request) {
152
+ if r.URL.Path == "/" {
153
+ frontend.ServePortalHTMLWithSSR(w, r, serv)
154
+ return
155
+ }
156
+ frontend.ServePortalStatic(w, r)
157
+ })(w, r)
158
+ })
159
160
// routes based on host and path
161
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cmd/webclient/httpjs/http_js.go
+3
-21
@@ -405,17 +405,10 @@ func JSRequestToHTTPRequest(jsReq js.Value) (*http.Request, error) {
405
406
// HTTPResponseToJSResponse converts an http.Response to a JavaScript Response object with streaming support
407
func HTTPResponseToJSResponse(httpResp *http.Response) js.Value {
408
- // Create JS Headers object (supports multiple values per key, e.g., Set-Cookie)
409
- jsHeaders := _Headers.New()
410
-
411
- // Extract Set-Cookie separately (browsers filter them from Response headers)
412
- // http.Header uses canonical form "Set-Cookie", so direct lookup is safe
413
- setCookies := httpResp.Header["Set-Cookie"]
408
+ // Create JS headers object
409
+ jsHeaders := _Object.New()
410
411
for key, values := range httpResp.Header {
416
- if key == "Set-Cookie" {
417
- continue // handled separately via _setCookies property
418
- }
412
for _, value := range values {
413
jsHeaders.Call("append", key, value)
414
}
@@ -436,19 +429,8 @@ func HTTPResponseToJSResponse(httpResp *http.Response) js.Value {
429
jsOptions.Set("statusText", httpResp.Status)
430
jsOptions.Set("headers", jsHeaders)
431
439
- // Create JS Response
432
+ // Create and return JS Response
433
jsResp := _Response.New(jsBody, jsOptions)
441
-
442
- // Attach Set-Cookie values as a custom property (browsers filter Set-Cookie from headers)
443
- // Service Worker will read this property and forward cookies via postMessage
444
- if len(setCookies) > 0 {
445
- jsArray := _Array.New(len(setCookies))
446
- for i, cookie := range setCookies {
447
- jsArray.SetIndex(i, cookie)
448
- }
449
- jsResp.Set("_setCookies", jsArray)
450
- }
451
-
434
return jsResp
435
}
436
cmd/webclient/polyfill.js
-29
@@ -18,35 +18,6 @@
18
}
19
}
20
21
- // Global Service Worker message handler registry
22
- // Key: clientId, Value: handler function
23
- const swMessageHandlers = new Map();
24
-
25
- // Single global Service Worker message listener
26
- if ("serviceWorker" in navigator) {
27
- navigator.serviceWorker.addEventListener("message", (event) => {
28
- const data = event.data;
29
- if (!data) return;
30
-
31
- // Handle SET_COOKIES globally (browsers ignore Set-Cookie in SW responses)
32
- if (data.type === "SET_COOKIES") {
33
- const cookies = data.cookies;
34
- if (Array.isArray(cookies)) {
35
- for (let i = 0; i < cookies.length; i++) {
36
- document.cookie = cookies[i];
37
- }
38
- }
39
- return;
40
- }
41
-
42
- // Route to per-client WebSocket handler
43
- if (data.clientId) {
44
- const handler = swMessageHandlers.get(data.clientId);
45
- if (handler) handler(data);
46
- }
47
- });
48
- }
49
-
21
// Generate unique client ID
22
function generateClientId() {
23
return `client-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
cmd/webclient/service-worker.js
-11
@@ -791,17 +791,6 @@ self.addEventListener("fetch", (e) => {
791
// Process request
792
const resp = await __go_jshttp(e.request);
793
794
- // Forward Set-Cookie values to client via postMessage
795
- // (browsers filter Set-Cookie from Response headers, so Go WASM attaches them as _setCookies)
796
- const setCookies = resp._setCookies;
797
- if (setCookies && setCookies.length > 0) {
798
- const msg = { type: "SET_COOKIES", cookies: setCookies };
799
- const clientId = e.clientId || e.resultingClientId;
800
- if (clientId) {
801
- self.clients.get(clientId).then(c => c && c.postMessage(msg));
802
- }
803
- }
804
-
794
return resp;
795
} catch (error) {
796
console.error("[SW] Request handling failed:", error);
portal/lease.go
+26
@@ -3,6 +3,7 @@ package portal
3
import (
4
"encoding/json"
5
"regexp"
6
+ "strings"
7
"sync"
8
"time"
9
@@ -249,6 +250,31 @@ func (lm *LeaseManager) GetLeaseByName(name string) (*LeaseEntry, bool) {
250
return nil, false
251
}
252
253
+// GetLeaseByNameFold returns a lease entry by name using case-insensitive matching.
254
+// This is needed because DNS subdomains are case-insensitive.
255
+func (lm *LeaseManager) GetLeaseByNameFold(name string) (*LeaseEntry, bool) {
256
+ lm.leasesLock.RLock()
257
+ defer lm.leasesLock.RUnlock()
258
+
259
+ if name == "" {
260
+ return nil, false
261
+ }
262
+
263
+ now := time.Now()
264
+ for _, lease := range lm.leases {
265
+ if strings.EqualFold(lease.Lease.Name, name) {
266
+ if _, banned := lm.bannedLeases[string(lease.Lease.Identity.Id)]; banned {
267
+ continue
268
+ }
269
+ if now.After(lease.Expires) {
270
+ continue
271
+ }
272
+ return lease, true
273
+ }
274
+ }
275
+ return nil, false
276
+}
277
+
278
func (lm *LeaseManager) GetAllLeases() []*rdverb.Lease {
279
lm.leasesLock.RLock()
280
defer lm.leasesLock.RUnlock()
portal/relay.go
+75
@@ -1,7 +1,10 @@
1
package portal
2
3
import (
4
+ "errors"
5
+ "fmt"
6
"io"
7
+ "net"
8
"sync"
9
"time"
10
@@ -12,6 +15,27 @@ import (
15
"gosuda.org/portal/portal/core/proto/rdverb"
16
)
17
18
+var (
19
+ // ErrLeaseNotFound is returned when the requested lease does not exist or has expired
20
+ ErrLeaseNotFound = errors.New("lease not found")
21
+ // ErrConnectionNotAvailable is returned when the tunnel connection for a lease is not available
22
+ ErrConnectionNotAvailable = errors.New("connection not available")
23
+)
24
+
25
+// portalAddr implements net.Addr for portal tunnel connections.
26
+type portalAddr string
27
+
28
+func (a portalAddr) Network() string { return "portal" }
29
+func (a portalAddr) String() string { return string(a) }
30
+
31
+// leaseNetConn wraps a SecureConnection as net.Conn for use with http.Transport.
32
+type leaseNetConn struct {
33
+ *cryptoops.SecureConnection
34
+}
35
+
36
+func (c *leaseNetConn) LocalAddr() net.Addr { return portalAddr(c.SecureConnection.LocalID()) }
37
+func (c *leaseNetConn) RemoteAddr() net.Addr { return portalAddr(c.SecureConnection.RemoteID()) }
38
+
39
type Connection struct {
40
conn io.ReadWriteCloser
41
sess *yamux.Session
@@ -274,6 +298,11 @@ func (g *RelayServer) GetLeaseByName(name string) (*LeaseEntry, bool) {
298
return g.leaseManager.GetLeaseByName(name)
299
}
300
301
+// GetLeaseByNameFold returns a lease entry by name using case-insensitive matching.
302
+func (g *RelayServer) GetLeaseByNameFold(name string) (*LeaseEntry, bool) {
303
+ return g.leaseManager.GetLeaseByNameFold(name)
304
+}
305
+
306
// IsConnectionActive checks if a connection with the given ID is still active
307
func (g *RelayServer) IsConnectionActive(connectionID int64) bool {
308
g.connectionsLock.RLock()
@@ -342,3 +371,49 @@ func (g *RelayServer) SetEstablishRelayCallback(
371
) {
372
g.onEstablishRelay = callback
373
}
374
+
375
+// DialLease establishes a direct connection to a tunnel client's lease,
376
+// using the relay server's own credential for the RDSEC handshake.
377
+// Returns a net.Conn that transparently encrypts/decrypts through the tunnel.
378
+func (g *RelayServer) DialLease(leaseID, alpn string) (net.Conn, error) {
379
+ // 1. Look up lease entry
380
+ leaseEntry, exists := g.leaseManager.GetLeaseByID(leaseID)
381
+ if !exists {
382
+ return nil, ErrLeaseNotFound
383
+ }
384
+
385
+ // 2. Get the tunnel client's yamux Connection
386
+ g.connectionsLock.RLock()
387
+ conn, connExists := g.connections[leaseEntry.ConnectionID]
388
+ g.connectionsLock.RUnlock()
389
+ if !connExists {
390
+ return nil, ErrConnectionNotAvailable
391
+ }
392
+
393
+ // 3. Forward CONNECTION_REQUEST to tunnel client and get acceptance
394
+ req := &rdverb.ConnectionRequest{
395
+ LeaseId: leaseID,
396
+ ClientIdentity: g.identity,
397
+ }
398
+ leaseStream, respCode, err := g.forwardConnectionRequest(conn, req)
399
+ if err != nil {
400
+ if leaseStream != nil {
401
+ leaseStream.Close()
402
+ }
403
+ return nil, fmt.Errorf("forward connection request: %w", err)
404
+ }
405
+ if respCode != rdverb.ResponseCode_RESPONSE_CODE_ACCEPTED {
406
+ leaseStream.Close()
407
+ return nil, ErrConnectionRejected
408
+ }
409
+
410
+ // 4. Perform RDSEC client handshake (relay acts as "client" to the tunnel)
411
+ handshaker := cryptoops.NewHandshaker(g.credential)
412
+ secConn, err := handshaker.ClientHandshake(leaseStream, alpn)
413
+ if err != nil {
414
+ leaseStream.Close()
415
+ return nil, fmt.Errorf("client handshake: %w", err)
416
+ }
417
+
418
+ return &leaseNetConn{SecureConnection: secConn}, nil
419
+}