feat: update TLS settings and enhance reverse connection handling

gosunuts committed Feb 26, 2026 at 20:41 UTC 7f5228651ef5531c6e74941f89d83a4944e0ea8f
5 files changed +134 -24
cmd/relay-server/registry.go
+10 -7
@@ -251,12 +251,15 @@ func (r *SDKRegistry) handleRenew(w http.ResponseWriter, req *http.Request, serv
251 }
252
253 // Re-register route if needed (e.g., router restarted while lease remained active).
254 - if err := registerSNIRoute(serv, entry.Lease.ID, entry.Lease.Name); err != nil {
255 - log.Warn().
256 - Err(err).
257 - Str("lease_id", entry.Lease.ID).
258 - Str("name", entry.Lease.Name).
259 - Msg("[Registry] Failed to refresh SNI route on renew")
254 + // Only TLS-enabled leases need SNI routes.
255 + if entry.Lease.TLSEnabled {
256 + if err := registerSNIRoute(serv, entry.Lease.ID, entry.Lease.Name); err != nil {
257 + log.Warn().
258 + Err(err).
259 + Str("lease_id", entry.Lease.ID).
260 + Str("name", entry.Lease.Name).
261 + Msg("[Registry] Failed to refresh SNI route on renew")
262 + }
263 }
264
265 writeJSON(w, map[string]any{
@@ -270,7 +273,7 @@ func registerSNIRoute(serv *portal.RelayServer, leaseID, name string) error {
273 return nil
274 }
275 if serv.BaseHost == "" {
273 - return nil
276 + return fmt.Errorf("base domain not configured (set PORTAL_URL)")
277 }
278 sniName := strings.ToLower(strings.TrimSpace(name)) + "." + serv.BaseHost
279 return sniRouter.RegisterRoute(sniName, leaseID, name)
portal/reverse_hub.go
+85 -16
@@ -3,6 +3,7 @@ package portal
3 import (
4 "fmt"
5 "net"
6 + "net/http"
7 "strings"
8 "sync"
9 "sync/atomic"
@@ -13,6 +14,10 @@ import (
14 )
15
16 const (
17 + // ReverseKeepaliveMarker keeps idle reverse websocket connections alive
18 + // before they are activated for a real client request.
19 + ReverseKeepaliveMarker = byte(0x00)
20 +
21 // HTTPStartMarker is sent by the relay to activate a reverse connection
22 // for HTTP proxy mode.
23 HTTPStartMarker = byte(0x01)
@@ -35,13 +40,22 @@ const (
40
41 // AuthFailureDelay is the delay before closing unauthorized connections (rate limiting).
42 AuthFailureDelay = 2 * time.Second
43 +
44 + // ReverseIdleKeepaliveInterval sends an idle keepalive byte to reduce
45 + // reverse websocket disconnections from intermediate idle timeouts.
46 + ReverseIdleKeepaliveInterval = 25 * time.Second
47 )
48
49 // ReverseConn wraps a net.Conn with lifecycle management for the connection pool.
50 type ReverseConn struct {
42 - Conn net.Conn
43 - done chan struct{}
44 - once sync.Once
51 + Conn net.Conn
52 + done chan struct{}
53 + active chan struct{}
54 + once sync.Once
55 + // activateOnce ensures active channel is closed exactly once.
56 + activateOnce sync.Once
57 + // writeMu serializes writes while the connection is idle.
58 + writeMu sync.Mutex
59 // closed tracks local close to help queue consumers skip stale entries.
60 closed atomic.Bool
61 }
@@ -49,8 +63,9 @@ type ReverseConn struct {
63 // NewReverseConn creates a new pooled connection.
64 func NewReverseConn(conn net.Conn) *ReverseConn {
65 return &ReverseConn{
52 - Conn: conn,
53 - done: make(chan struct{}),
66 + Conn: conn,
67 + done: make(chan struct{}),
68 + active: make(chan struct{}),
69 }
70 }
71
@@ -72,6 +87,30 @@ func (c *ReverseConn) IsClosed() bool {
87 return c == nil || c.closed.Load()
88 }
89
90 +func (c *ReverseConn) Activate() {
91 + if c == nil {
92 + return
93 + }
94 + c.activateOnce.Do(func() {
95 + close(c.active)
96 + })
97 +}
98 +
99 +func (c *ReverseConn) WriteControlByte(marker byte, timeout time.Duration) error {
100 + if c == nil || c.Conn == nil {
101 + return net.ErrClosed
102 + }
103 + c.writeMu.Lock()
104 + defer c.writeMu.Unlock()
105 +
106 + if timeout > 0 {
107 + _ = c.Conn.SetWriteDeadline(time.Now().Add(timeout))
108 + defer c.Conn.SetWriteDeadline(time.Time{})
109 + }
110 + _, err := c.Conn.Write([]byte{marker})
111 + return err
112 +}
113 +
114 type ReverseHub struct {
115 mu sync.RWMutex
116 pools map[string]chan *ReverseConn
@@ -195,10 +234,9 @@ func (h *ReverseHub) acquireWithStartMarker(leaseID string, timeout time.Duratio
234 if conn == nil || conn.IsClosed() {
235 continue
236 }
198 - // Signal tunnel worker to release this connection to application Accept().
199 - _ = conn.Conn.SetWriteDeadline(time.Now().Add(2 * time.Second))
200 - _, err := conn.Conn.Write([]byte{marker})
201 - _ = conn.Conn.SetWriteDeadline(time.Time{})
237 + // Stop idle keepalive and signal tunnel worker to release this connection.
238 + conn.Activate()
239 + err := conn.WriteControlByte(marker, 2*time.Second)
240 if err == nil {
241 return conn, nil
242 }
@@ -251,15 +289,12 @@ func (h *ReverseHub) ClearDropped(leaseID string) {
289 }
290
291 func (h *ReverseHub) HandleConnect(ws *websocket.Conn) {
292 + if ws == nil {
293 + return
294 + }
295 ws.PayloadType = websocket.BinaryFrame
296
256 - req := ws.Request()
257 - leaseID := ""
258 - token := ""
259 - if req != nil {
260 - leaseID = strings.TrimSpace(req.URL.Query().Get("lease_id"))
261 - token = strings.TrimSpace(req.URL.Query().Get("token"))
262 - }
297 + leaseID, token := parseReverseConnectCredentials(ws.Request())
298
299 if leaseID == "" {
300 log.Warn().Msg("[ReverseHub] Missing lease_id on reverse connect")
@@ -282,6 +317,40 @@ func (h *ReverseHub) HandleConnect(ws *websocket.Conn) {
317 return
318 }
319
320 + h.keepAliveWhileIdle(conn, leaseID)
321 +
322 // Wait until the connection is used and closed
323 conn.Wait()
324 }
325 +
326 +func parseReverseConnectCredentials(req *http.Request) (leaseID, token string) {
327 + if req == nil || req.URL == nil {
328 + return "", ""
329 + }
330 + leaseID = strings.TrimSpace(req.URL.Query().Get("lease_id"))
331 + token = strings.TrimSpace(req.URL.Query().Get("token"))
332 + return leaseID, token
333 +}
334 +
335 +func (h *ReverseHub) keepAliveWhileIdle(conn *ReverseConn, leaseID string) {
336 + ticker := time.NewTicker(ReverseIdleKeepaliveInterval)
337 + defer ticker.Stop()
338 +
339 + for {
340 + select {
341 + case <-conn.done:
342 + return
343 + case <-conn.active:
344 + return
345 + case <-ticker.C:
346 + if err := conn.WriteControlByte(ReverseKeepaliveMarker, 2*time.Second); err != nil {
347 + log.Debug().
348 + Err(err).
349 + Str("lease_id", leaseID).
350 + Msg("[ReverseHub] Idle keepalive write failed")
351 + conn.Close()
352 + return
353 + }
354 + }
355 + }
356 +}
sdk/cert.go
+1 -1
@@ -26,7 +26,7 @@ type CertificateClient struct {
26 func NewCertificateClient(relayAPIURL string) *CertificateClient {
27 return &CertificateClient{
28 relayAPIURL: relayAPIURL,
29 - httpClient: &http.Client{Timeout: 60 * time.Second},
29 + httpClient: &http.Client{Timeout: 600 * time.Second},
30 }
31 }
32
sdk/listener.go
+6
@@ -270,6 +270,9 @@ func (l *Listener) reverseAcceptWorker(workerID int) {
270 if errors.Is(err, net.ErrClosed) {
271 return
272 }
273 + if errors.Is(err, io.EOF) {
274 + continue
275 + }
276 log.Debug().
277 Err(err).
278 Str("lease_id", l.lease.ID).
@@ -318,6 +321,9 @@ func (l *Listener) waitForReverseStart(conn net.Conn, expectedMarker byte) error
321 _, err := io.ReadFull(conn, marker[:])
322 if err == nil {
323 _ = conn.SetReadDeadline(time.Time{})
324 + if marker[0] == portal.ReverseKeepaliveMarker {
325 + continue
326 + }
327 if marker[0] == expectedMarker {
328 return nil
329 }
sdk/listener_test.go
+32
@@ -152,6 +152,38 @@ func TestWaitForReverseStart_TLSMode(t *testing.T) {
152 }
153 }
154
155 +func TestWaitForReverseStart_IgnoresKeepaliveMarker(t *testing.T) {
156 + t.Parallel()
157 +
158 + l := &Listener{stopCh: make(chan struct{})}
159 + local, peer := net.Pipe()
160 + defer local.Close()
161 + defer peer.Close()
162 +
163 + done := make(chan error, 1)
164 + go func() {
165 + done <- l.waitForReverseStart(local, portal.HTTPStartMarker)
166 + }()
167 +
168 + _, err := peer.Write([]byte{portal.ReverseKeepaliveMarker})
169 + if err != nil {
170 + t.Fatalf("write keepalive marker: %v", err)
171 + }
172 + _, err = peer.Write([]byte{portal.HTTPStartMarker})
173 + if err != nil {
174 + t.Fatalf("write start marker: %v", err)
175 + }
176 +
177 + select {
178 + case err := <-done:
179 + if err != nil {
180 + t.Fatalf("waitForReverseStart failed: %v", err)
181 + }
182 + case <-time.After(500 * time.Millisecond):
183 + t.Fatal("timed out waiting for marker")
184 + }
185 +}
186 +
187 func TestWaitForReverseStart_TLSRejectsHTTPMarker(t *testing.T) {
188 t.Parallel()
189