From 35f17f57ba80342ef7a455a48c52e838efd30e5f Mon Sep 17 00:00:00 2001 From: Clement Tee Date: Fri, 24 Jul 2026 16:08:06 +0800 Subject: [PATCH 1/4] feat: support downstream TLS for Web App resources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parse the tls flag in the GAT token's gateway_metadata.downstream and run the inner TLS upgrade for Web App connections when it is set, presenting the gateway certificate — the same mechanism Kubernetes connections use. The fake Twingate client gains a WithDownstreamTLS option and tools/local runs an extra Web App client with it for local HTTPS testing. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01X9Z4JjodvSrpkMw964Lcgx --- internal/token/gat_claims.go | 5 ++- internal/token/gat_claims_test.go | 58 ++++++++++++++++++++++++++----- test/fake/client.go | 12 ++++++- tools/local/main.go | 51 +++++++++++++++++++-------- 4 files changed, 102 insertions(+), 24 deletions(-) diff --git a/internal/token/gat_claims.go b/internal/token/gat_claims.go index a8af1f5b..89bf94d9 100644 --- a/internal/token/gat_claims.go +++ b/internal/token/gat_claims.go @@ -82,7 +82,8 @@ func validatePort(port int, fieldName string) error { } func (p GATClaims) ShouldUpgradeTLS() bool { - return p.Resource.Type == ResourceTypeKubernetes + return p.Resource.Type == ResourceTypeKubernetes || + (p.Resource.Type == ResourceTypeWebApp && p.Resource.GatewayMetadata.Downstream.TLS) } func (p GATClaims) getHeaderType() string { @@ -148,6 +149,8 @@ type GatewayMetadata struct { type Downstream struct { // Port is the port that the protocol client connects to. Port int `json:"port"` + // TLS indicates whether the Gateway terminates TLS for the protocol client. + TLS bool `json:"tls"` } // Upstream describes the connection between the Gateway and the upstream resource. diff --git a/internal/token/gat_claims_test.go b/internal/token/gat_claims_test.go index 9039c0e4..079a0fde 100644 --- a/internal/token/gat_claims_test.go +++ b/internal/token/gat_claims_test.go @@ -7,6 +7,7 @@ import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" + "encoding/json" "fmt" "math/big" "testing" @@ -19,9 +20,10 @@ import ( func TestGATClaims_ShouldUpgradeTLS(t *testing.T) { tests := []struct { - name string - resourceType ResourceType - expected bool + name string + resourceType ResourceType + downstreamTLS bool + expected bool }{ { name: "Kubernetes should upgrade TLS", @@ -29,21 +31,33 @@ func TestGATClaims_ShouldUpgradeTLS(t *testing.T) { expected: true, }, { - name: "SSH should not upgrade TLS", - resourceType: ResourceTypeSSH, - expected: false, + name: "SSH should not upgrade TLS", + resourceType: ResourceTypeSSH, + downstreamTLS: true, + expected: false, }, { - name: "Web app should not upgrade TLS", + name: "Web app without downstream TLS should not upgrade TLS", resourceType: ResourceTypeWebApp, expected: false, }, + { + name: "Web app with downstream TLS should upgrade TLS", + resourceType: ResourceTypeWebApp, + downstreamTLS: true, + expected: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { claims := &GATClaims{ - Resource: Resource{Type: tt.resourceType}, + Resource: Resource{ + Type: tt.resourceType, + GatewayMetadata: GatewayMetadata{ + Downstream: Downstream{TLS: tt.downstreamTLS}, + }, + }, } assert.Equal(t, tt.expected, claims.ShouldUpgradeTLS()) }) @@ -320,3 +334,31 @@ func TestPublicKey_UnmarshalJSON(t *testing.T) { }) } } + +func TestGatewayMetadata_UnmarshalDownstreamTLS(t *testing.T) { + tests := []struct { + name string + json string + wantTLS bool + }{ + { + name: "downstream tls true", + json: `{"downstream": {"port": 443, "tls": true}}`, + wantTLS: true, + }, + { + name: "tls absent defaults to false", + json: `{"downstream": {"port": 443}}`, + wantTLS: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var metadata GatewayMetadata + + require.NoError(t, json.Unmarshal([]byte(tt.json), &metadata)) + assert.Equal(t, tt.wantTLS, metadata.Downstream.TLS) + }) + } +} diff --git a/test/fake/client.go b/test/fake/client.go index 51400b0e..7ee09640 100644 --- a/test/fake/client.go +++ b/test/fake/client.go @@ -46,6 +46,7 @@ type Client struct { resourceHostname string downstreamPort int + downstreamTLS bool upstreamPort int resourceType token.ResourceType requestHeaderRewrites map[string]string @@ -73,6 +74,15 @@ func WithRequestHeaderRewrites(rewrites map[string]string) Option { } } +// WithDownstreamTLS marks the resource as TLS-enforced on downstream in the GAT +// and switches the client-facing port to the HTTPS port. +func WithDownstreamTLS() Option { + return func(c *Client) { + c.downstreamTLS = true + c.downstreamPort = 443 + } +} + // NewClient creates a new Client. upstreamAddress must include both the host and the port that // the backend actually listens on. The client-facing downstream port used in the CONNECT request // is derived from resourceType. The Gateway rewrites it to the upstream port before forwarding @@ -278,7 +288,7 @@ func (c *Client) fetchGAT() (string, error) { Type: c.resourceType, Address: c.resourceHostname, GatewayMetadata: token.GatewayMetadata{ - Downstream: token.Downstream{Port: c.downstreamPort}, + Downstream: token.Downstream{Port: c.downstreamPort, TLS: c.downstreamTLS}, Upstream: token.Upstream{Port: c.upstreamPort}, RequestHeaderRewrites: c.requestHeaderRewrites, }, diff --git a/tools/local/main.go b/tools/local/main.go index a01835e2..cd45b268 100644 --- a/tools/local/main.go +++ b/tools/local/main.go @@ -142,15 +142,17 @@ func main() { } }() + webAppGeo := token.GeoIPLocation{ + Lat: 37.5, + Lon: -122.4, + Country: "US", + Region: "CA", + City: "San Mateo", + } + webAppClient := fake.NewClient( user, - token.GeoIPLocation{ - Lat: 37.5, - Lon: -122.4, - Country: "US", - Region: "CA", - City: "San Mateo", - }, + webAppGeo, fmt.Sprintf("%s:%d", gatewayHost, gatewayPort), controller.URL, echoServer.address, @@ -160,6 +162,19 @@ func main() { logger.Info("Web app fake Twingate client is serving at", zap.String("address", webAppClient.Address)) + webAppTLSClient := fake.NewClient( + user, + webAppGeo, + fmt.Sprintf("%s:%d", gatewayHost, gatewayPort), + controller.URL, + echoServer.address, + token.ResourceTypeWebApp, + fake.WithDownstreamTLS(), + ) + defer webAppTLSClient.Close() + + logger.Info("Web app (downstream TLS) fake Twingate client is serving at", zap.String("address", webAppTLSClient.Address)) + err = createLocalGatewayConfig(kindBearerToken) if err != nil { logger.Error("Failed to create local gateway config", zap.Error(err)) @@ -176,11 +191,12 @@ func main() { Twingate local dev environment running! ===================================================== - Controller: %s - User: %s - Client (Kubernetes): %s - Client (SSH): %s - Client (Web App): %s + Controller: %s + User: %s + Client (Kubernetes): %s + Client (SSH): %s + Client (Web App HTTP): %s + Client (Web App HTTPS): %s ----------------------------------------------------- 1. Start the Gateway (in a separate terminal): @@ -208,15 +224,22 @@ Twingate local dev environment running! curl http://%s +----------------------------------------------------- +5. Test Web App over HTTPS (TLS terminates at the Gateway + using the gateway certificate, which covers 127.0.0.1): + + curl --cacert ./test/data/proxy/tls.crt https://%s + ----------------------------------------------------- Press Ctrl+C to stop ===================================================== -`, controller.URL, user.Username, kubernetesClient.Address, sshClient.Address, webAppClient.Address, +`, controller.URL, user.Username, kubernetesClient.Address, sshClient.Address, webAppClient.Address, webAppTLSClient.Address, gatewayRunCmd, kubeConfigFile, kubeConfigFile, kindClusterName, sshClientPort, sshKnownHostFile, webAppClient.Address, + webAppTLSClient.Address, ) //nolint:forbidigo @@ -253,7 +276,7 @@ ssh: manual: privateKeyFile: ./test/data/ssh/ca/ca webApp: - headers: + requestHeaders: Authorization: "Bearer {{jwt}}" X-Twingate-User: "{{username}}" X-Twingate-Groups: "{{groups}}" From ff47e6edf5f6f94f27a2e3fcf6c19a2fa0327e26 Mon Sep 17 00:00:00 2001 From: Clement Tee Date: Wed, 29 Jul 2026 16:45:50 +0800 Subject: [PATCH 2/4] Better instruction --- tools/local/main.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tools/local/main.go b/tools/local/main.go index cd45b268..bd2ad170 100644 --- a/tools/local/main.go +++ b/tools/local/main.go @@ -222,12 +222,10 @@ Twingate local dev environment running! ----------------------------------------------------- 4. Test Web App header forwarding: + Over HTTP: curl http://%s ------------------------------------------------------ -5. Test Web App over HTTPS (TLS terminates at the Gateway - using the gateway certificate, which covers 127.0.0.1): - + Over HTTPS: curl --cacert ./test/data/proxy/tls.crt https://%s ----------------------------------------------------- From 4143c8e0b297daa456b3eda8e2edd97f4d8b7ada Mon Sep 17 00:00:00 2001 From: Clement Tee Date: Fri, 21 Aug 2026 23:17:09 +0800 Subject: [PATCH 3/4] Address review --- internal/token/gat_claims.go | 14 +++++++++++--- tools/local/main.go | 4 ++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/internal/token/gat_claims.go b/internal/token/gat_claims.go index 89bf94d9..12d95b53 100644 --- a/internal/token/gat_claims.go +++ b/internal/token/gat_claims.go @@ -82,8 +82,16 @@ func validatePort(port int, fieldName string) error { } func (p GATClaims) ShouldUpgradeTLS() bool { - return p.Resource.Type == ResourceTypeKubernetes || - (p.Resource.Type == ResourceTypeWebApp && p.Resource.GatewayMetadata.Downstream.TLS) + switch p.Resource.Type { + case ResourceTypeKubernetes: + return true + case ResourceTypeWebApp: + return p.Resource.GatewayMetadata.Downstream.TLS + case ResourceTypeSSH: + return false + } + + return false } func (p GATClaims) getHeaderType() string { @@ -149,7 +157,7 @@ type GatewayMetadata struct { type Downstream struct { // Port is the port that the protocol client connects to. Port int `json:"port"` - // TLS indicates whether the Gateway terminates TLS for the protocol client. + // TLS indicates whether the Gateway should enforce TLS for the protocol client. TLS bool `json:"tls"` } diff --git a/tools/local/main.go b/tools/local/main.go index bd2ad170..e87e13d3 100644 --- a/tools/local/main.go +++ b/tools/local/main.go @@ -160,7 +160,7 @@ func main() { ) defer webAppClient.Close() - logger.Info("Web app fake Twingate client is serving at", zap.String("address", webAppClient.Address)) + logger.Info("Web app HTTP fake Twingate client is serving at", zap.String("address", webAppClient.Address)) webAppTLSClient := fake.NewClient( user, @@ -173,7 +173,7 @@ func main() { ) defer webAppTLSClient.Close() - logger.Info("Web app (downstream TLS) fake Twingate client is serving at", zap.String("address", webAppTLSClient.Address)) + logger.Info("Web app HTTPS fake Twingate client is serving at", zap.String("address", webAppTLSClient.Address)) err = createLocalGatewayConfig(kindBearerToken) if err != nil { From 66f15acb51cad94a5d476da7b353f287278f6aee Mon Sep 17 00:00:00 2001 From: Clement Tee Date: Tue, 25 Aug 2026 11:37:48 +0800 Subject: [PATCH 4/4] Set `X-forwarded-proto` header --- internal/webapphandler/handler.go | 11 +++++ internal/webapphandler/handler_test.go | 58 ++++++++++++++++++++++++++ test/integration/web_app_test.go | 2 + 3 files changed, 71 insertions(+) diff --git a/internal/webapphandler/handler.go b/internal/webapphandler/handler.go index 25fa158c..57f24055 100644 --- a/internal/webapphandler/handler.go +++ b/internal/webapphandler/handler.go @@ -67,6 +67,15 @@ func buildVariables(conn *connect.ProxyConn) map[string]string { // these are the identity headers it leaves in place. var clientIdentityHeaders = []string{"X-Real-IP", "X-Forwarded-Port", "X-Forwarded-Server"} +// downstreamScheme reports the scheme the protocol client used to reach the Gateway. +func downstreamScheme(conn *connect.ProxyConn) string { + if conn.GATClaims().ShouldUpgradeTLS() { + return "https" + } + + return "http" +} + func rewrite(r *httputil.ProxyRequest, conn *connect.ProxyConn, headers map[string]*template.Template) error { targetURL := &url.URL{ Scheme: "http", // plain HTTP — no upstream TLS @@ -79,6 +88,8 @@ func rewrite(r *httputil.ProxyRequest, conn *connect.ProxyConn, headers map[stri r.Out.Header.Del(headerName) } + r.Out.Header.Set("X-Forwarded-Proto", downstreamScheme(conn)) + variables := buildVariables(conn) for headerName, tmpl := range headers { diff --git a/internal/webapphandler/handler_test.go b/internal/webapphandler/handler_test.go index 80e9d603..300e2d55 100644 --- a/internal/webapphandler/handler_test.go +++ b/internal/webapphandler/handler_test.go @@ -303,3 +303,61 @@ func TestBuildVariables_CoversAllowedKeys(t *testing.T) { assert.Equal(t, want, got) } + +func TestRewrite_SetsXForwardedProto(t *testing.T) { + tests := []struct { + name string + downstreamTLS bool + clientSuppliedXFP string + want string + }{ + { + name: "HTTPS when the Gateway terminates TLS downstream", + downstreamTLS: true, + want: "https", + }, + { + name: "HTTP when the protocol client connects in plaintext", + downstreamTLS: false, + want: "http", + }, + { + name: "client-supplied value is overwritten", + downstreamTLS: false, + clientSuppliedXFP: "https", + want: "http", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + connMetrics := connect.CreateProxyConnMetrics(prometheus.NewRegistry()) + conn := connect.NewProxyConn(nil, nil, nil, zap.NewNop(), connMetrics) + conn.UpstreamHost = "admin.example.int" + conn.Claims = &token.GATClaims{ + Resource: token.Resource{ + Type: token.ResourceTypeWebApp, + GatewayMetadata: token.GatewayMetadata{ + Downstream: token.Downstream{Port: 443, TLS: tt.downstreamTLS}, + Upstream: token.Upstream{Port: 80}, + }, + }, + } + + outReq := httptest.NewRequest(http.MethodGet, "http://admin.example.int/path", nil) + if tt.clientSuppliedXFP != "" { + outReq.Header.Set("X-Forwarded-Proto", tt.clientSuppliedXFP) + } + + proxyReq := &httputil.ProxyRequest{ + In: httptest.NewRequest(http.MethodGet, "http://admin.example.int/path", nil), + Out: outReq, + } + + err := rewrite(proxyReq, conn, nil) + require.NoError(t, err) + + assert.Equal(t, tt.want, proxyReq.Out.Header.Get("X-Forwarded-Proto")) + }) + } +} diff --git a/test/integration/web_app_test.go b/test/integration/web_app_test.go index d0dccd20..45f29e92 100644 --- a/test/integration/web_app_test.go +++ b/test/integration/web_app_test.go @@ -118,6 +118,8 @@ func TestWebApp(t *testing.T) { "X-Twingate-Client-Geo-Country": "US", // From GAT Token "X-Twingate-Username": "alex@acme.com", + // Downstream scheme + "X-Forwarded-Proto": "http", } for header, expected := range expectedHeaders {