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