sni: route root domain on :443 to internal API while keeping wildcard passthrough

rabbitprincess committed Mar 2, 2026 at 12:08 UTC ebf84e89a6a94c6b77de4bf830bf2c39549b2192
4 files changed +195 -72
cmd/relay-server/admin.go
+4
@@ -311,12 +311,14 @@ func (a *Admin) handleLogin(w http.ResponseWriter, r *http.Request) {
311 // Successful login
312 a.authManager.ResetFailedLogin(clientIP)
313 token := a.authManager.CreateSession()
314 + secureCookie := isSecureRequest(r)
315
316 http.SetCookie(w, &http.Cookie{
317 Name: adminCookieName,
318 Value: token,
319 Path: "/admin",
320 HttpOnly: true,
321 + Secure: secureCookie,
322 SameSite: http.SameSiteStrictMode,
323 MaxAge: 86400, // 24 hours
324 })
@@ -333,6 +335,7 @@ func (a *Admin) handleLogout(w http.ResponseWriter, r *http.Request) {
335 if err == nil && cookie.Value != "" {
336 a.authManager.DeleteSession(cookie.Value)
337 }
338 + secureCookie := isSecureRequest(r)
339
340 // Clear the cookie
341 http.SetCookie(w, &http.Cookie{
@@ -340,6 +343,7 @@ func (a *Admin) handleLogout(w http.ResponseWriter, r *http.Request) {
343 Value: "",
344 Path: "/admin",
345 HttpOnly: true,
346 + Secure: secureCookie,
347 SameSite: http.SameSiteStrictMode,
348 MaxAge: -1, // Delete cookie
349 })
cmd/relay-server/serve.go
+101 -66
@@ -11,11 +11,13 @@ import (
11 "net/http"
12 "strconv"
13 "strings"
14 + "time"
15
16 "github.com/rs/zerolog/log"
17
18 "gosuda.org/portal/portal"
19 "gosuda.org/portal/portal/keyless"
20 + "gosuda.org/portal/portal/sni"
21 "gosuda.org/portal/sdk"
22 )
23
@@ -103,11 +105,11 @@ func serveAPI(addr string, serv *portal.RelayServer, admin *Admin, frontend *Fro
105 Str("host", r.Host).
106 Str("url", r.URL.String()).
107 Msg("[server] handling subdomain request")
106 - // Check if the tunnel has TLS enabled by looking up the lease
107 - if shouldProxyHTTP(r.Host, serv) {
108 + leaseName, leaseEntry, shouldProxy := shouldProxyHTTP(r.Host, serv)
109 + if shouldProxy {
110 // TLS is not enabled on the tunnel, proxy via HTTP
111 log.Debug().Str("host", r.Host).Msg("[server] proxying to HTTP")
110 - proxyToHTTP(w, r, serv)
112 + proxyToHTTP(w, r, serv, leaseName, leaseEntry)
113 return
114 }
115 // TLS is enabled, redirect to HTTPS.
@@ -124,6 +126,7 @@ func serveAPI(addr string, serv *portal.RelayServer, admin *Admin, frontend *Fro
126 }
127 acmeManager := serv.GetACMEManager()
128 tlsCertFile, tlsKeyFile := acmeManager.TLSFiles()
129 + configurePortalRootFallback(addr, serv)
130
131 go func() {
132 var err error
@@ -147,72 +150,20 @@ func serveAPI(addr string, serv *portal.RelayServer, admin *Admin, frontend *Fro
150 return srv
151 }
152
150 -func handleKeylessSign(w http.ResponseWriter, r *http.Request, signer *keyless.Signer) {
151 - if signer == nil {
152 - writeSignError(w, http.StatusNotFound, "keyless signer is disabled")
153 - return
154 - }
155 -
156 - if r.Method != http.MethodPost {
157 - w.Header().Set("Allow", http.MethodPost)
158 - writeSignError(w, http.StatusMethodNotAllowed, "method not allowed")
159 - return
160 - }
161 -
162 - if ct := r.Header.Get("Content-Type"); ct != "" && !strings.HasPrefix(ct, "application/json") {
163 - writeSignError(w, http.StatusUnsupportedMediaType, "content type must be application/json")
164 - return
165 - }
166 -
167 - defer r.Body.Close()
168 -
169 - var req keyless.SignRequest
170 - if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
171 - writeSignError(w, http.StatusBadRequest, "invalid json body")
172 - return
173 - }
174 -
175 - resp, err := signer.Sign(r.Context(), &req)
176 - if err != nil {
177 - status := http.StatusInternalServerError
178 - switch {
179 - case errors.Is(err, keyless.ErrSignerDisabled):
180 - status = http.StatusNotFound
181 - case errors.Is(err, keyless.ErrInvalidArgument):
182 - status = http.StatusBadRequest
183 - case errors.Is(err, keyless.ErrPermissionDenied):
184 - status = http.StatusForbidden
185 - }
186 - writeSignError(w, status, err.Error())
187 - return
188 - }
189 -
190 - w.Header().Set("Content-Type", "application/json")
191 - if err := json.NewEncoder(w).Encode(resp); err != nil {
192 - log.Error().Err(err).Msg("[signer] failed to encode sign response")
193 - writeSignError(w, http.StatusInternalServerError, "failed to encode response")
194 - }
195 -}
196 -
197 -func writeSignError(w http.ResponseWriter, status int, message string) {
198 - w.Header().Set("Content-Type", "application/json")
199 - w.WriteHeader(status)
200 - _ = json.NewEncoder(w).Encode(keyless.ErrorResponse{Error: message})
201 -}
202 -
153 // shouldProxyHTTP checks if the request should be proxied via HTTP.
204 -// Returns true if TLS mode is no-tls.
205 -func shouldProxyHTTP(host string, serv *portal.RelayServer) bool {
154 +// It returns leaseName, lease entry, and whether HTTP proxying should be used.
155 +func shouldProxyHTTP(host string, serv *portal.RelayServer) (string, *portal.LeaseEntry, bool) {
156 leaseName, ok := leaseNameFromHost(host, defaultAppPattern(flagPortalURL))
157 if !ok {
158 log.Debug().Str("host", host).Msg("[proxy] shouldProxyHTTP: failed to extract lease name")
209 - return false
159 + return "", nil, false
160 }
161
162 entry, ok := serv.GetLeaseManager().GetLeaseByName(leaseName)
163 if !ok {
164 log.Debug().Str("lease_name", leaseName).Msg("[proxy] shouldProxyHTTP: lease not found")
215 - return true
165 + // Keep existing behavior: unknown subdomain goes through proxy path and returns 404.
166 + return leaseName, nil, true
167 }
168
169 // If TLS mode is no-tls, we can proxy via HTTP.
@@ -221,18 +172,16 @@ func shouldProxyHTTP(host string, serv *portal.RelayServer) bool {
172 Str("lease_name", leaseName).
173 Str("tls_mode", entry.Lease.TLSMode).
174 Msg("[proxy] shouldProxyHTTP")
224 - return shouldProxy
175 + return leaseName, entry, shouldProxy
176 }
177
227 -func proxyToHTTP(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer) {
228 - leaseName, ok := leaseNameFromHost(r.Host, defaultAppPattern(flagPortalURL))
229 - if !ok {
178 +func proxyToHTTP(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer, leaseName string, entry *portal.LeaseEntry) {
179 + if leaseName == "" {
180 http.Error(w, "invalid subdomain", http.StatusBadRequest)
181 return
182 }
183
234 - entry, ok := serv.GetLeaseManager().GetLeaseByName(leaseName)
235 - if !ok {
184 + if entry == nil {
185 http.Error(w, "service not found", http.StatusNotFound)
186 return
187 }
@@ -322,3 +271,89 @@ func redirectToHTTPS(w http.ResponseWriter, r *http.Request, sniListenAddr strin
271 }
272 http.Redirect(w, r, target, http.StatusMovedPermanently)
273 }
274 +
275 +func handleKeylessSign(w http.ResponseWriter, r *http.Request, signer *keyless.Signer) {
276 + if signer == nil {
277 + writeSignError(w, http.StatusNotFound, "keyless signer is disabled")
278 + return
279 + }
280 +
281 + if r.Method != http.MethodPost {
282 + w.Header().Set("Allow", http.MethodPost)
283 + writeSignError(w, http.StatusMethodNotAllowed, "method not allowed")
284 + return
285 + }
286 +
287 + if ct := r.Header.Get("Content-Type"); ct != "" && !strings.HasPrefix(ct, "application/json") {
288 + writeSignError(w, http.StatusUnsupportedMediaType, "content type must be application/json")
289 + return
290 + }
291 +
292 + defer r.Body.Close()
293 +
294 + var req keyless.SignRequest
295 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
296 + writeSignError(w, http.StatusBadRequest, "invalid json body")
297 + return
298 + }
299 +
300 + resp, err := signer.Sign(r.Context(), &req)
301 + if err != nil {
302 + status := http.StatusInternalServerError
303 + switch {
304 + case errors.Is(err, keyless.ErrSignerDisabled):
305 + status = http.StatusNotFound
306 + case errors.Is(err, keyless.ErrInvalidArgument):
307 + status = http.StatusBadRequest
308 + case errors.Is(err, keyless.ErrPermissionDenied):
309 + status = http.StatusForbidden
310 + }
311 + writeSignError(w, status, err.Error())
312 + return
313 + }
314 +
315 + w.Header().Set("Content-Type", "application/json")
316 + if err := json.NewEncoder(w).Encode(resp); err != nil {
317 + log.Error().Err(err).Msg("[signer] failed to encode sign response")
318 + writeSignError(w, http.StatusInternalServerError, "failed to encode response")
319 + }
320 +}
321 +
322 +func configurePortalRootFallback(adminListenAddr string, serv *portal.RelayServer) {
323 + portalRootSNI := portalRootHost(flagPortalURL)
324 + if portalRootSNI == "" {
325 + return
326 + }
327 +
328 + apiAddr, ok := loopbackForwardAddr(adminListenAddr)
329 + if !ok {
330 + log.Warn().
331 + Str("listen_addr", adminListenAddr).
332 + Msg("[SNI] invalid admin listen address; root-domain fallback disabled")
333 + return
334 + }
335 +
336 + serv.GetSNIRouter().SetNoRouteHandler(func(clientConn net.Conn, serverName string) bool {
337 + if !strings.EqualFold(strings.TrimSpace(serverName), portalRootSNI) {
338 + return false
339 + }
340 +
341 + upstreamConn, err := net.DialTimeout("tcp", apiAddr, 5*time.Second)
342 + if err != nil {
343 + log.Warn().
344 + Err(err).
345 + Str("sni", serverName).
346 + Str("upstream", apiAddr).
347 + Msg("[SNI] failed to forward root domain to admin/API listener")
348 + clientConn.Close()
349 + return true
350 + }
351 +
352 + log.Debug().
353 + Str("sni", serverName).
354 + Str("upstream", apiAddr).
355 + Msg("[SNI] forwarding root domain to admin/API listener")
356 + sni.BridgeConnections(clientConn, upstreamConn)
357 + return true
358 + })
359 +}
cmd/relay-server/utils.go
+67
@@ -4,8 +4,10 @@ import (
4 "encoding/base64"
5 "encoding/json"
6 "fmt"
7 + "net"
8 "net/http"
9 "net/url"
10 + "strconv"
11 "strings"
12 "time"
13
@@ -13,9 +15,23 @@ import (
15
16 "gosuda.org/portal/cmd/relay-server/manager"
17 "gosuda.org/portal/portal"
18 + "gosuda.org/portal/portal/keyless"
19 "gosuda.org/portal/sdk"
20 )
21
22 +func isSecureRequest(r *http.Request) bool {
23 + if r == nil {
24 + return false
25 + }
26 + if r.TLS != nil {
27 + return true
28 + }
29 + if strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")), "https") {
30 + return true
31 + }
32 + return strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Ssl")), "on")
33 +}
34 +
35 // parseURLs splits a comma-separated string into a list of trimmed, non-empty URLs.
36 func parseURLs(raw string) []string {
37 raw = strings.TrimSpace(raw)
@@ -139,6 +155,51 @@ func portalHostPort(portalURL string) string {
155 ))
156 }
157
158 +// loopbackForwardAddr resolves a listen address into 127.0.0.1:<port>.
159 +func loopbackForwardAddr(listenAddr string) (string, bool) {
160 + raw := strings.TrimSpace(listenAddr)
161 + if raw == "" {
162 + return "", false
163 + }
164 +
165 + port := ""
166 + switch {
167 + case strings.HasPrefix(raw, ":"):
168 + port = strings.TrimPrefix(raw, ":")
169 + case strings.Count(raw, ":") == 0:
170 + port = raw
171 + default:
172 + _, p, err := net.SplitHostPort(raw)
173 + if err != nil {
174 + return "", false
175 + }
176 + port = p
177 + }
178 +
179 + portNum, err := strconv.Atoi(port)
180 + if err != nil || portNum < 1 || portNum > 65535 {
181 + return "", false
182 + }
183 +
184 + return net.JoinHostPort("127.0.0.1", strconv.Itoa(portNum)), true
185 +}
186 +
187 +func portalRootHost(portalURL string) string {
188 + raw := strings.TrimSpace(portalURL)
189 + if raw == "" {
190 + return ""
191 + }
192 + if !strings.Contains(raw, "://") {
193 + raw = "https://" + raw
194 + }
195 +
196 + parsed, err := url.Parse(raw)
197 + if err != nil || parsed.Hostname() == "" {
198 + return ""
199 + }
200 + return strings.TrimPrefix(strings.ToLower(strings.TrimSpace(parsed.Hostname())), "*.")
201 +}
202 +
203 // servicePublicURL returns a service URL derived from portalURL and service name.
204 func servicePublicURL(portalURL, serviceName string) string {
205 serviceName = strings.TrimSpace(serviceName)
@@ -417,6 +478,12 @@ func writeJSON(w http.ResponseWriter, v any) {
478 }
479 }
480
481 +func writeSignError(w http.ResponseWriter, status int, message string) {
482 + w.Header().Set("Content-Type", "application/json")
483 + w.WriteHeader(status)
484 + _ = json.NewEncoder(w).Encode(keyless.ErrorResponse{Error: message})
485 +}
486 +
487 func decodeLeaseID(encoded string) (string, bool) {
488 idBytes, err := base64.URLEncoding.DecodeString(encoded)
489 if err != nil {
portal/sni/router.go
+23 -6
@@ -43,6 +43,8 @@ type Router struct {
43
44 // Callback for new connections
45 onConnection func(conn net.Conn, route *Route)
46 + // Callback for SNI connections that do not match any registered route.
47 + onNoRoute func(conn net.Conn, sni string) bool
48
49 stopCh chan struct{}
50 stopOnce sync.Once
@@ -71,6 +73,14 @@ func (r *Router) SetConnectionCallback(cb func(conn net.Conn, route *Route)) {
73 r.onConnection = cb
74 }
75
76 +// SetNoRouteHandler sets the callback for unmatched SNI connections.
77 +// Return true when the callback handled the connection lifecycle.
78 +func (r *Router) SetNoRouteHandler(cb func(conn net.Conn, sni string) bool) {
79 + r.mu.Lock()
80 + defer r.mu.Unlock()
81 + r.onNoRoute = cb
82 +}
83 +
84 // RegisterRoute registers a new route for an SNI
85 func (r *Router) RegisterRoute(sni, leaseID, leaseName string) error {
86 r.mu.Lock()
@@ -290,9 +300,22 @@ func (r *Router) handleConnection(clientConn net.Conn) {
300 // Clear the deadline
301 clientConn.SetReadDeadline(time.Time{})
302
303 + // Wrap the connection so callbacks can still read the peeked bytes.
304 + wrappedConn := &peekedConn{
305 + Conn: clientConn,
306 + reader: peekedReader,
307 + }
308 +
309 // Find the route
310 route, ok := r.GetRoute(sni)
311 if !ok {
312 + r.mu.RLock()
313 + onNoRoute := r.onNoRoute
314 + r.mu.RUnlock()
315 + if onNoRoute != nil && onNoRoute(wrappedConn, sni) {
316 + return
317 + }
318 +
319 log.Warn().
320 Str("sni", sni).
321 Str("remote", clientConn.RemoteAddr().String()).
@@ -307,12 +330,6 @@ func (r *Router) handleConnection(clientConn net.Conn) {
330 Str("remote", clientConn.RemoteAddr().String()).
331 Msg("[SNI] Route found")
332
310 - // Wrap the connection so the callback can still read the peeked bytes.
311 - wrappedConn := &peekedConn{
312 - Conn: clientConn,
313 - reader: peekedReader,
314 - }
315 -
333 // Call the connection callback if set
334 r.mu.RLock()
335 onConnection := r.onConnection