feat: enhance SNI handling and improve HTTPS redirection logic

gosunuts committed Feb 24, 2026 at 18:57 UTC 377cb138ba4b971dd32543bb3d7ce409c32ebaa1
4 files changed +65 -37
cmd/relay-server/main.go
+8 -15
@@ -118,27 +118,20 @@ func runServer() error {
118 // Get BPS manager for rate limiting
119 bpsManager := admin.GetBPSManager()
120
121 - // Prefer reverse tunnel (NAT-friendly) if available.
122 - if reverseConn, err := serv.GetReverseHub().AcquireStarted(route.LeaseID, portal.ReverseSNIAcquireWait); err == nil {
123 - defer reverseConn.Close()
124 - manager.EstablishRelayWithBPS(clientConn, reverseConn.Conn, route.LeaseID, bpsManager)
125 - return
126 - }
127 -
128 - // Connect to tunnel backend
129 - tunnelConn, err := net.DialTimeout("tcp", route.TargetAddr, 10*time.Second)
121 + reverseConn, err := serv.GetReverseHub().AcquireStarted(route.LeaseID, portal.ReverseSNIAcquireWait)
122 if err != nil {
131 - log.Error().
123 + log.Warn().
124 Err(err).
133 - Str("target", route.TargetAddr).
125 + Str("lease_id", route.LeaseID).
126 Str("sni", route.SNI).
135 - Msg("[SNI] Failed to connect to tunnel backend")
127 + Msg("[SNI] Reverse tunnel unavailable")
128 clientConn.Close()
129 return
130 }
131 + defer reverseConn.Close()
132
140 - // Establish relay with BPS limiting
141 - manager.EstablishRelayWithBPS(clientConn, tunnelConn, route.LeaseID, bpsManager)
133 + // SNI path is reverse-only (NAT-friendly): relay never dials app directly.
134 + manager.EstablishRelayWithBPS(clientConn, reverseConn.Conn, route.LeaseID, bpsManager)
135 })
136
137 // Start SNI router on port 443 (or configurable port)
@@ -158,7 +151,7 @@ func runServer() error {
151 serv.Start()
152 defer serv.Stop()
153
161 - httpSrv := serveHTTP(fmt.Sprintf(":%d", flagPort), serv, sniRouter, admin, frontend, flagNoIndex, stop)
154 + httpSrv := serveHTTP(fmt.Sprintf(":%d", flagPort), sniPort, serv, sniRouter, admin, frontend, flagNoIndex, stop)
155
156 <-ctx.Done()
157 log.Info().Msg("[server] shutting down...")
cmd/relay-server/serve.go
+49 -7
@@ -8,6 +8,7 @@ import (
8 "io"
9 "net"
10 "net/http"
11 + "strconv"
12 "strings"
13
14 "github.com/rs/zerolog/log"
@@ -22,7 +23,7 @@ import (
23 var distFS embed.FS
24
25 // serveHTTP builds the HTTP mux and returns the server.
25 -func serveHTTP(addr string, serv *portal.RelayServer, sniRouter *sni.Router, admin *Admin, frontend *Frontend, noIndex bool, cancel context.CancelFunc) *http.Server {
26 +func serveHTTP(addr, sniListenAddr string, serv *portal.RelayServer, sniRouter *sni.Router, admin *Admin, frontend *Frontend, noIndex bool, cancel context.CancelFunc) *http.Server {
27 if addr == "" {
28 addr = ":0"
29 }
@@ -100,10 +101,9 @@ func serveHTTP(addr string, serv *portal.RelayServer, sniRouter *sni.Router, adm
101 proxyToHTTP(w, r, serv)
102 return
103 }
103 - // TLS is enabled, redirect to HTTPS
104 - // The SNI router handles TLS passthrough on :443.
104 + // TLS is enabled, redirect to HTTPS.
105 log.Debug().Str("host", r.Host).Msg("[server] redirecting to HTTPS")
106 - redirectToHTTPS(w, r)
106 + redirectToHTTPS(w, r, sniListenAddr)
107 return
108 }
109 appMux.ServeHTTP(w, r)
@@ -155,9 +155,10 @@ func leaseNameFromHost(host, appURL string) (string, bool) {
155 return leaseName, true
156 }
157
158 -// redirectToHTTPS redirects the request to HTTPS on port 443
159 -func redirectToHTTPS(w http.ResponseWriter, r *http.Request) {
160 - target := "https://" + r.Host + r.URL.Path
158 +// redirectToHTTPS redirects the request to HTTPS using configured SNI port.
159 +func redirectToHTTPS(w http.ResponseWriter, r *http.Request, sniListenAddr string) {
160 + targetHost := hostForHTTPSRedirect(r.Host, sniListenAddr)
161 + target := "https://" + targetHost + r.URL.Path
162 if r.URL.RawQuery != "" {
163 target += "?" + r.URL.RawQuery
164 }
@@ -168,6 +169,47 @@ func redirectToHTTPS(w http.ResponseWriter, r *http.Request) {
169 http.Redirect(w, r, target, http.StatusMovedPermanently)
170 }
171
172 +func hostForHTTPSRedirect(requestHost, sniListenAddr string) string {
173 + host := strings.TrimSpace(requestHost)
174 + if parsedHost, _, err := net.SplitHostPort(host); err == nil {
175 + host = parsedHost
176 + }
177 +
178 + port := tlsPortForRedirect(sniListenAddr)
179 + if port == "443" {
180 + return host
181 + }
182 +
183 + return net.JoinHostPort(host, port)
184 +}
185 +
186 +func tlsPortForRedirect(sniListenAddr string) string {
187 + raw := strings.TrimSpace(sniListenAddr)
188 + if raw == "" {
189 + return "443"
190 + }
191 +
192 + port := ""
193 + switch {
194 + case strings.HasPrefix(raw, ":"):
195 + port = strings.TrimPrefix(raw, ":")
196 + case strings.Count(raw, ":") == 0:
197 + port = raw
198 + default:
199 + _, parsedPort, err := net.SplitHostPort(raw)
200 + if err != nil {
201 + return "443"
202 + }
203 + port = parsedPort
204 + }
205 +
206 + n, err := strconv.Atoi(port)
207 + if err != nil || n < 1 || n > 65535 {
208 + return "443"
209 + }
210 + return port
211 +}
212 +
213 // shouldProxyHTTP checks if the request should be proxied via HTTP
214 // based on the lease's TLSEnabled setting.
215 // Returns true if TLS is NOT enabled (can proxy via HTTP).
sdk/client.go
+1 -1
@@ -62,7 +62,7 @@ func (c *Client) Listen(name string, options ...MetadataOption) (net.Listener, e
62 return nil, ErrInvalidName
63 }
64
65 - var metadata Metadata
65 + var metadata portal.Metadata
66 for _, option := range options {
67 option(&metadata)
68 }
sdk/types.go
+7 -14
@@ -112,42 +112,35 @@ func WithTLSAutocertDir(dir string) ClientOption {
112 }
113 }
114
115 -type Metadata struct {
116 - Description string `json:"description"`
117 - Tags []string `json:"tags"`
118 - Thumbnail string `json:"thumbnail"`
119 - Owner string `json:"owner"`
120 - Hide bool `json:"hide"`
121 -}
122 -
123 -type MetadataOption func(*Metadata)
115 +// MetadataOption configures Metadata
116 +type MetadataOption func(*portal.Metadata)
117
118 func WithDescription(description string) MetadataOption {
126 - return func(m *Metadata) {
119 + return func(m *portal.Metadata) {
120 m.Description = description
121 }
122 }
123
124 func WithTags(tags []string) MetadataOption {
132 - return func(m *Metadata) {
125 + return func(m *portal.Metadata) {
126 m.Tags = tags
127 }
128 }
129
130 func WithThumbnail(thumbnail string) MetadataOption {
138 - return func(m *Metadata) {
131 + return func(m *portal.Metadata) {
132 m.Thumbnail = thumbnail
133 }
134 }
135
136 func WithOwner(owner string) MetadataOption {
144 - return func(m *Metadata) {
137 + return func(m *portal.Metadata) {
138 m.Owner = owner
139 }
140 }
141
142 func WithHide(hide bool) MetadataOption {
150 - return func(m *Metadata) {
143 + return func(m *portal.Metadata) {
144 m.Hide = hide
145 }
146 }