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