Skip to content

Commit d4a3a41

Browse files
fix: tighten tunnel and gateway request handling
1 parent a775c61 commit d4a3a41

21 files changed

Lines changed: 1205 additions & 65 deletions

File tree

.env.example

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
11
CINDER_GATEWAY_ADDR=:8081
22
CINDER_EDGE_ADDR=:8080
33
CINDER_BASE_DOMAIN=example.test
4+
CINDER_ALLOWED_ORIGINS=
5+
CINDER_MAX_BODY_BYTES=10485760
6+
CINDER_GATEWAY_CONNECT_RATE=10
7+
CINDER_GATEWAY_CONNECT_BURST=20
8+
CINDER_ROUTE_REGISTER_RATE=5
9+
CINDER_ROUTE_REGISTER_BURST=10
10+
CINDER_EDGE_REQUEST_RATE=20
11+
CINDER_EDGE_REQUEST_BURST=40
412
CINDER_DEV_API_KEY=dev-secret-token
513
CINDER_DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/cinder_link?sslmode=disable
614
CINDER_SERVER_URL=ws://127.0.0.1:8081/ws
15+
CINDER_TLS_INSECURE=false
716
CINDER_API_TOKEN=dev-secret-token
817
CINDER_ACCOUNT_ID=acc_personal_dev

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,14 @@ export CINDER_DATABASE_URL='postgres://postgres:postgres@127.0.0.1:5432/cinder_l
4747

4848
`tunneld` automatically runs SQL migrations on startup when `CINDER_DATABASE_URL` is set.
4949

50+
## TLS and production notes
51+
52+
- Use a TLS-terminating reverse proxy in front of `tunneld` for production `wss` traffic.
53+
- Keep `CINDER_TLS_INSECURE=false` in production; it exists only for local development against self-signed endpoints.
54+
- Set `CINDER_ALLOWED_ORIGINS` when browser clients need to reach the gateway from a different origin than the request host.
55+
- `CINDER_MAX_BODY_BYTES` defaults to `10485760` (10 MiB) and caps request/response bodies until chunked streaming lands.
56+
- Gateway and edge rate limits are configurable with `CINDER_GATEWAY_CONNECT_RATE`, `CINDER_ROUTE_REGISTER_RATE`, and `CINDER_EDGE_REQUEST_RATE` plus matching `*_BURST` env vars.
57+
5058
## Quick start
5159

5260
Run the hosted service:

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ require (
5050
golang.org/x/sync v0.17.0 // indirect
5151
golang.org/x/sys v0.35.0 // indirect
5252
golang.org/x/text v0.29.0 // indirect
53+
golang.org/x/time v0.15.0 // indirect
5354
golang.org/x/tools v0.36.0 // indirect
5455
google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
5556
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,8 @@ golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
155155
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
156156
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
157157
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
158+
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
159+
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
158160
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
159161
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
160162
golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=

internal/app/tunneld/app.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,8 @@ func Run(ctx context.Context, args []string) error {
7373
hub := httpstream.NewHub()
7474
reconnectManager := reconnect.NewManager(cfg.GraceWindow, sessions.NewResumePersistence(sessionRepo), logger.With(slog.String("component", "reconnect")))
7575

76-
wsServer := gatewayws.NewServer(logger.With(slog.String("component", "gateway")), tokenValidator, cfg.HeartbeatTimeout, sessionRepo, routeRepo, quotaStore, hub, reconnectManager)
77-
edgeServer := httpedge.NewServer(logger.With(slog.String("component", "edge")), routeRepo, hub)
76+
wsServer := gatewayws.NewServer(logger.With(slog.String("component", "gateway")), tokenValidator, cfg.HeartbeatTimeout, cfg.BaseDomain, cfg.AllowedOrigins, cfg.GatewayConnectRate, cfg.GatewayConnectBurst, cfg.RouteRegisterRate, cfg.RouteRegisterBurst, sessionRepo, routeRepo, quotaStore, hub, reconnectManager)
77+
edgeServer := httpedge.NewServer(logger.With(slog.String("component", "edge")), routeRepo, hub, cfg.MaxBodyBytes, cfg.EdgeRequestRate, cfg.EdgeRequestBurst)
7878

7979
gatewayHTTP := &http.Server{Addr: cfg.GatewayAddr, Handler: muxGateway(wsServer.Handler(), database)}
8080
edgeHTTP := &http.Server{Addr: cfg.EdgeAddr, Handler: edgeServer.Handler()}

internal/client/commands/root.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ func runAgent(ctx context.Context, args []string) error {
5858
}
5959
return agent.Run(ctx, agent.Config{
6060
SocketPath: *socketPath,
61-
Client: session.Client{ServerURL: *serverURL, APIToken: *apiToken, AccountID: *accountID, Hostname: *name},
61+
Client: session.Client{ServerURL: *serverURL, APIToken: *apiToken, AccountID: *accountID, Hostname: *name, TLSInsecure: defaults.TLSInsecure},
6262
})
6363
}
6464

@@ -73,7 +73,7 @@ func runConnect(ctx context.Context, args []string) error {
7373
return err
7474
}
7575

76-
client := session.Client{ServerURL: *serverURL, APIToken: *apiToken, AccountID: *accountID, Hostname: *name}
76+
client := session.Client{ServerURL: *serverURL, APIToken: *apiToken, AccountID: *accountID, Hostname: *name, TLSInsecure: config.LoadClient().TLSInsecure}
7777
result, conn, err := client.Connect(ctx)
7878
if err != nil {
7979
return err
@@ -105,7 +105,7 @@ func runOpen(ctx context.Context, args []string) error {
105105
return fmt.Errorf("--subdomain is required")
106106
}
107107

108-
client := session.Client{ServerURL: *serverURL, APIToken: *apiToken, AccountID: *accountID, Hostname: *name}
108+
client := session.Client{ServerURL: *serverURL, APIToken: *apiToken, AccountID: *accountID, Hostname: *name, TLSInsecure: config.LoadClient().TLSInsecure}
109109
result, conn, err := client.Connect(ctx)
110110
if err != nil {
111111
return err

internal/client/session/session.go

Lines changed: 83 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ import (
99
"io"
1010
"net/http"
1111
"net/url"
12+
"os"
1213
"runtime"
1314
"slices"
15+
"strconv"
1416
"strings"
1517
"sync"
1618
"time"
@@ -21,10 +23,11 @@ import (
2123
)
2224

2325
type Client struct {
24-
ServerURL string
25-
APIToken string
26-
AccountID string
27-
Hostname string
26+
ServerURL string
27+
APIToken string
28+
AccountID string
29+
Hostname string
30+
TLSInsecure bool
2831
}
2932

3033
type ConnectResult struct {
@@ -59,8 +62,10 @@ type managedStream struct {
5962

6063
var heartbeatInterval = 15 * time.Second
6164

65+
const httpBodyChunkSize = 16 << 10
66+
6267
func (c Client) Connect(ctx context.Context) (ConnectResult, *ws.Conn, error) {
63-
conn, _, err := ws.Dial(ctx, c.ServerURL, nil)
68+
conn, _, err := ws.Dial(ctx, c.ServerURL, c.dialOptions())
6469
if err != nil {
6570
return ConnectResult{}, nil, fmt.Errorf("dial websocket: %w", err)
6671
}
@@ -86,7 +91,7 @@ func (c Client) Connect(ctx context.Context) (ConnectResult, *ws.Conn, error) {
8691
}
8792

8893
func (c Client) Resume(ctx context.Context, sessionID, sessionToken string) (ConnectResult, *ws.Conn, error) {
89-
conn, _, err := ws.Dial(ctx, c.ServerURL, nil)
94+
conn, _, err := ws.Dial(ctx, c.ServerURL, c.dialOptions())
9095
if err != nil {
9196
return ConnectResult{}, nil, fmt.Errorf("dial websocket: %w", err)
9297
}
@@ -107,6 +112,14 @@ func (c Client) Resume(ctx context.Context, sessionID, sessionToken string) (Con
107112
return result, conn, nil
108113
}
109114

115+
func (c Client) dialOptions() *ws.DialOptions {
116+
if !c.TLSInsecure {
117+
return nil
118+
}
119+
transport := &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
120+
return &ws.DialOptions{HTTPClient: &http.Client{Transport: transport}}
121+
}
122+
110123
func (c Client) RegisterRoute(ctx context.Context, conn *ws.Conn, subdomain string, target map[string]interface{}) (protocol.RouteRegisteredPayload, error) {
111124
frame := protocol.NewEnvelope(protocol.TypeRouteRegister, protocol.ControlStreamID, protocol.RouteRegisterPayload{
112125
RequestedSubdomain: subdomain,
@@ -515,6 +528,9 @@ func proxyRequest(ctx context.Context, httpClient *http.Client, manager *streamM
515528
if err != nil {
516529
return err
517530
}
531+
if headers.ContentLength >= 0 {
532+
req.ContentLength = headers.ContentLength
533+
}
518534
if headers.Host != "" {
519535
req.Host = headers.Host
520536
}
@@ -536,16 +552,9 @@ func proxyRequest(ctx context.Context, httpClient *http.Client, manager *streamM
536552
if err := manager.write(ctx, responseHeaders); err != nil {
537553
return err
538554
}
539-
540-
respBody, err := io.ReadAll(resp.Body)
541-
if err != nil {
555+
if err := sendBodyReader(ctx, manager, env, protocol.TypeHTTPRespBody, resp.Body, maxTunnelBodyBytes()); err != nil {
542556
return err
543557
}
544-
if len(respBody) > 0 {
545-
if err := manager.write(ctx, protocol.Envelope{Version: 1, Type: protocol.TypeHTTPRespBody, SessionID: env.SessionID, StreamID: env.StreamID, RouteID: env.RouteID, Timestamp: time.Now().UTC(), Payload: protocol.HTTPBodyPayload{Base64: true, Data: base64.StdEncoding.EncodeToString(respBody)}}); err != nil {
546-
return err
547-
}
548-
}
549558
return manager.write(ctx, protocol.Envelope{Version: 1, Type: protocol.TypeHTTPRespEnd, SessionID: env.SessionID, StreamID: env.StreamID, RouteID: env.RouteID, Timestamp: time.Now().UTC(), Payload: map[string]any{}})
550559
}
551560

@@ -599,6 +608,66 @@ func decodeBody(payload protocol.HTTPBodyPayload) ([]byte, error) {
599608
return base64.StdEncoding.DecodeString(payload.Data)
600609
}
601610

611+
func maxTunnelBodyBytes() int64 {
612+
const fallback = int64(10 << 20)
613+
value := strings.TrimSpace(os.Getenv("CINDER_MAX_BODY_BYTES"))
614+
if value == "" {
615+
return fallback
616+
}
617+
parsed, err := strconv.ParseInt(value, 10, 64)
618+
if err != nil || parsed <= 0 {
619+
return fallback
620+
}
621+
return parsed
622+
}
623+
624+
func sendBodyReader(ctx context.Context, manager *streamManager, env protocol.IncomingEnvelope, frameType string, body io.Reader, maxBytes int64) error {
625+
if body == nil {
626+
return nil
627+
}
628+
remaining := maxBytes
629+
buf := make([]byte, httpBodyChunkSize)
630+
for {
631+
readSize := len(buf)
632+
if maxBytes > 0 && int64(readSize) > remaining+1 {
633+
readSize = int(remaining + 1)
634+
}
635+
n, err := body.Read(buf[:readSize])
636+
if n > 0 {
637+
if maxBytes > 0 && int64(n) > remaining {
638+
return fmt.Errorf("body exceeds max size of %d bytes", maxBytes)
639+
}
640+
if maxBytes > 0 {
641+
remaining -= int64(n)
642+
}
643+
if err := manager.write(ctx, protocol.Envelope{Version: 1, Type: frameType, SessionID: env.SessionID, StreamID: env.StreamID, RouteID: env.RouteID, Timestamp: time.Now().UTC(), Payload: protocol.HTTPBodyPayload{Base64: true, Data: base64.StdEncoding.EncodeToString(buf[:n])}}); err != nil {
644+
return err
645+
}
646+
}
647+
if err != nil {
648+
if err == io.EOF {
649+
return nil
650+
}
651+
return err
652+
}
653+
}
654+
}
655+
656+
func readBodyWithLimit(r io.Reader, maxBytes int64) ([]byte, error) {
657+
if maxBytes <= 0 {
658+
return io.ReadAll(r)
659+
}
660+
limited := io.LimitReader(r, maxBytes+1)
661+
body, err := io.ReadAll(limited)
662+
if err != nil {
663+
return nil, err
664+
}
665+
if int64(len(body)) > maxBytes {
666+
return nil, fmt.Errorf("body exceeds max size of %d bytes", maxBytes)
667+
}
668+
return body, nil
669+
}
670+
602671
func stripHopByHopHeaders(headers map[string][]string) map[string][]string {
603672
if len(headers) == 0 {
604673
return nil

internal/client/session/session_test.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import (
88
"net/http"
99
"net/http/httptest"
1010
"net/url"
11+
"os"
12+
"strings"
1113
"sync"
1214
"testing"
1315
"time"
@@ -55,6 +57,17 @@ func TestClosePayloadFromErrorDefaults(t *testing.T) {
5557
}
5658
}
5759

60+
func TestClientDialOptionsUsesInsecureTLSWhenRequested(t *testing.T) {
61+
options := (Client{TLSInsecure: true}).dialOptions()
62+
if options == nil || options.HTTPClient == nil {
63+
t.Fatal("expected dial options with HTTP client")
64+
}
65+
transport, ok := options.HTTPClient.Transport.(*http.Transport)
66+
if !ok || transport.TLSClientConfig == nil || !transport.TLSClientConfig.InsecureSkipVerify {
67+
t.Fatal("expected insecure TLS transport when TLSInsecure is enabled")
68+
}
69+
}
70+
5871
func TestReadConnectResultAuthOK(t *testing.T) {
5972
conn := newTestWebsocketConn(t, protocol.NewEnvelope(protocol.TypeAuthOK, protocol.ControlStreamID, protocol.AuthOKPayload{
6073
SessionID: "ses_123",
@@ -382,6 +395,109 @@ func TestManagedStreamSendDoesNotDeadlockOnClose(t *testing.T) {
382395
}
383396
}
384397

398+
func TestReadRequestBodyConcatenatesChunks(t *testing.T) {
399+
stream := make(chan protocol.IncomingEnvelope, 3)
400+
stream <- protocol.IncomingEnvelope{Type: protocol.TypeHTTPReqBody, Payload: []byte(`{"base64":true,"data":"aGVs"}`)}
401+
stream <- protocol.IncomingEnvelope{Type: protocol.TypeHTTPReqBody, Payload: []byte(`{"base64":true,"data":"bG8="}`)}
402+
stream <- protocol.IncomingEnvelope{Type: protocol.TypeHTTPReqEnd, Payload: []byte(`{}`)}
403+
close(stream)
404+
405+
data, err := readRequestBody(context.Background(), stream)
406+
if err != nil {
407+
t.Fatalf("read request body: %v", err)
408+
}
409+
if string(data) != "hello" {
410+
t.Fatalf("expected concatenated body hello, got %q", string(data))
411+
}
412+
}
413+
414+
func TestSendBodyReaderSplitsLargePayloadIntoMultipleFrames(t *testing.T) {
415+
serverConn := make(chan *ws.Conn, 1)
416+
frameCh := make(chan protocol.IncomingEnvelope, 4)
417+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
418+
conn, err := ws.Accept(w, r, nil)
419+
if err != nil {
420+
t.Errorf("accept websocket: %v", err)
421+
return
422+
}
423+
serverConn <- conn
424+
for i := 0; i < 4; i++ {
425+
_, data, err := conn.Read(context.Background())
426+
if err != nil {
427+
return
428+
}
429+
var env protocol.IncomingEnvelope
430+
if err := json.Unmarshal(data, &env); err != nil {
431+
t.Errorf("decode frame: %v", err)
432+
return
433+
}
434+
frameCh <- env
435+
}
436+
}))
437+
defer server.Close()
438+
439+
clientConn, _, err := ws.Dial(context.Background(), "ws"+server.URL[len("http"):], nil)
440+
if err != nil {
441+
t.Fatalf("dial test websocket: %v", err)
442+
}
443+
defer clientConn.CloseNow()
444+
conn := <-serverConn
445+
defer conn.CloseNow()
446+
447+
manager := &streamManager{conn: clientConn, streams: make(map[string]*managedStream), closed: make(map[string]struct{}), newStreams: make(chan protocol.IncomingEnvelope, 1)}
448+
payload := strings.Repeat("a", httpBodyChunkSize*2+17)
449+
env := protocol.IncomingEnvelope{SessionID: "ses_1", StreamID: "str_1", RouteID: "rte_1"}
450+
if err := sendBodyReader(context.Background(), manager, env, protocol.TypeHTTPRespBody, strings.NewReader(payload), int64(len(payload))); err != nil {
451+
t.Fatalf("send body reader: %v", err)
452+
}
453+
454+
total := 0
455+
frames := 0
456+
for i := 0; i < 3; i++ {
457+
got := <-frameCh
458+
if got.Type != protocol.TypeHTTPRespBody {
459+
t.Fatalf("expected body frame, got %s", got.Type)
460+
}
461+
var bodyPayload protocol.HTTPBodyPayload
462+
if err := got.UnmarshalPayload(&bodyPayload); err != nil {
463+
t.Fatalf("unmarshal body payload: %v", err)
464+
}
465+
chunk, err := decodeBody(bodyPayload)
466+
if err != nil {
467+
t.Fatalf("decode chunk: %v", err)
468+
}
469+
total += len(chunk)
470+
frames++
471+
}
472+
if frames != 3 || total != len(payload) {
473+
t.Fatalf("expected 3 frames totaling %d bytes, got %d frames totaling %d bytes", len(payload), frames, total)
474+
}
475+
}
476+
477+
func TestReadBodyWithLimitRejectsOversizedBody(t *testing.T) {
478+
body, err := readBodyWithLimit(strings.NewReader("12345"), 4)
479+
if err == nil {
480+
t.Fatalf("expected limit error, got body %q", string(body))
481+
}
482+
}
483+
484+
func TestMaxTunnelBodyBytesUsesEnvOverride(t *testing.T) {
485+
oldValue, hadValue := os.LookupEnv("CINDER_MAX_BODY_BYTES")
486+
t.Cleanup(func() {
487+
if hadValue {
488+
_ = os.Setenv("CINDER_MAX_BODY_BYTES", oldValue)
489+
} else {
490+
_ = os.Unsetenv("CINDER_MAX_BODY_BYTES")
491+
}
492+
})
493+
if err := os.Setenv("CINDER_MAX_BODY_BYTES", "2048"); err != nil {
494+
t.Fatalf("set env: %v", err)
495+
}
496+
if got := maxTunnelBodyBytes(); got != 2048 {
497+
t.Fatalf("expected env override to be used, got %d", got)
498+
}
499+
}
500+
385501
func newTestWebsocketConn(t *testing.T, env protocol.Envelope) *ws.Conn {
386502
t.Helper()
387503
serverConn := make(chan *ws.Conn, 1)

0 commit comments

Comments
 (0)