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