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
}