diff --git a/internal/config/config.go b/internal/config/config.go index a50a1f63..9f98ccf1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,6 +16,8 @@ import ( "go.uber.org/zap" "go.yaml.in/yaml/v4" "golang.org/x/crypto/ssh" + + "gateway/internal/util/useragent" ) var ( @@ -251,6 +253,7 @@ func resolveTwingateHostname(targetURL, defaultHost string, retryMax int, logger logger = logger.With(zap.String("url", targetURL), zap.String("defaultHost", defaultHost)) client := retryablehttp.NewClient() + client.HTTPClient.Transport = useragent.Transport{Base: client.HTTPClient.Transport} client.HTTPClient.Timeout = 1 * time.Second client.HTTPClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse diff --git a/internal/config/config_test.go b/internal/config/config_test.go index f434b9a7..e78eac17 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -108,6 +108,26 @@ func TestResolveTwingateHostname(t *testing.T) { assert.Equal(t, "twingate.com", result) }) + t.Run("identifies the gateway with a User-Agent header", func(t *testing.T) { + userAgents := make(chan string, 1) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + userAgents <- r.UserAgent() + + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(server.Close) + + resolveTwingateHostname(server.URL+"/api/v1/jwk/ec", "twingate.com", 0, zap.NewNop()) + + select { + case userAgent := <-userAgents: + assert.Equal(t, "Twingate-Gateway/dev", userAgent) + default: + t.Fatal("hostname resolution endpoint was not requested") + } + }) + t.Run("does not follow redirect", func(t *testing.T) { shardServerCalled := make(chan struct{}, 1) diff --git a/internal/connect/connect_test.go b/internal/connect/connect_test.go index 623e1ec6..38a70368 100644 --- a/internal/connect/connect_test.go +++ b/internal/connect/connect_test.go @@ -79,7 +79,7 @@ func createParserAndGATToken(t *testing.T, claims token.GATClaims) (*token.Parse privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) require.NoError(t, err) - parser, err := token.NewParser(token.ParserConfig{ + parser, err := token.NewParser(t.Context(), token.ParserConfig{ Issuer: "twingate", Audience: "acme", Keyfunc: func(_token *jwt.Token) (any, error) { diff --git a/internal/connect/listener.go b/internal/connect/listener.go index 91e1e696..88a5ec87 100644 --- a/internal/connect/listener.go +++ b/internal/connect/listener.go @@ -86,13 +86,14 @@ type Listener struct { } func NewListener( + ctx context.Context, twingateConfig config.TwingateConfig, tlsCfg config.TLSConfig, channels map[token.ResourceType]chan<- Conn, registry *prometheus.Registry, logger *zap.Logger, ) (*Listener, error) { - tokenParser, err := token.NewParser(token.ParserConfig{ + tokenParser, err := token.NewParser(ctx, token.ParserConfig{ Issuer: twingateConfig.Issuer(), Audience: twingateConfig.Network, JWKSURL: twingateConfig.JWKSURL(), diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index 4ac6c6c5..96e6c83e 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -150,6 +150,7 @@ func (p *Proxy) Start() error { } connectListener, err := connect.NewListener( + ctx, p.config.Twingate, p.config.TLS, channels, diff --git a/internal/token/parser.go b/internal/token/parser.go index 9bd0f8e6..46085060 100644 --- a/internal/token/parser.go +++ b/internal/token/parser.go @@ -4,10 +4,14 @@ package token import ( + "context" "fmt" + "net/http" "github.com/MicahParks/keyfunc/v3" "github.com/golang-jwt/jwt/v5" + + "gateway/internal/util/useragent" ) var allowedSigningMethods = []string{jwt.SigningMethodES256.Alg()} @@ -28,9 +32,11 @@ type Parser struct { config ParserConfig } -func NewParser(config ParserConfig) (*Parser, error) { +func NewParser(ctx context.Context, config ParserConfig) (*Parser, error) { if config.Keyfunc == nil { - jwks, err := keyfunc.NewDefault([]string{config.JWKSURL}) + jwks, err := keyfunc.NewDefaultOverrideCtx(ctx, []string{config.JWKSURL}, keyfunc.Override{ + Client: &http.Client{Transport: useragent.Transport{}}, + }) if err != nil { return nil, fmt.Errorf("failed to create JWKS store: %w", err) } diff --git a/internal/token/parser_test.go b/internal/token/parser_test.go index 7915966b..71e03e40 100644 --- a/internal/token/parser_test.go +++ b/internal/token/parser_test.go @@ -7,10 +7,13 @@ import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" + "net/http" + "net/http/httptest" "testing" "time" "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -63,7 +66,7 @@ func newTokenService() *tokenService { func TestNewParser(t *testing.T) { tokenService := newTokenService() - parser, err := NewParser(ParserConfig{ + parser, err := NewParser(t.Context(), ParserConfig{ Issuer: "twingate", Audience: "acme", Keyfunc: tokenService.keyfunc, @@ -142,3 +145,32 @@ func TestNewParser(t *testing.T) { }) } } + +func TestNewParser_RemoteJWKS(t *testing.T) { + userAgents := make(chan string, 1) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case userAgents <- r.UserAgent(): + default: + } + + _, _ = w.Write([]byte(`{"keys":[]}`)) + })) + t.Cleanup(server.Close) + + parser, err := NewParser(t.Context(), ParserConfig{ + Issuer: "twingate", + Audience: "acme", + JWKSURL: server.URL + "/api/v1/jwk/ec", + }) + require.NoError(t, err) + require.NotNil(t, parser) + + select { + case userAgent := <-userAgents: + assert.Equal(t, "Twingate-Gateway/dev", userAgent) + default: + t.Fatal("JWKS endpoint was not requested") + } +} diff --git a/internal/util/useragent/useragent.go b/internal/util/useragent/useragent.go new file mode 100644 index 00000000..8c0f2634 --- /dev/null +++ b/internal/util/useragent/useragent.go @@ -0,0 +1,32 @@ +// Copyright (c) Twingate Inc. +// SPDX-License-Identifier: MPL-2.0 + +package useragent + +import ( + "net/http" + + "gateway/internal/version" +) + +// String returns the User-Agent identifying this build in outbound HTTP requests. +func String() string { + return "Twingate-Gateway/" + version.Version +} + +type Transport struct { + // Base is the transport the request is handed to. If nil, http.DefaultTransport is used. + Base http.RoundTripper +} + +func (t Transport) RoundTrip(req *http.Request) (*http.Response, error) { + base := t.Base + if base == nil { + base = http.DefaultTransport + } + + req = req.Clone(req.Context()) + req.Header.Set("User-Agent", String()) + + return base.RoundTrip(req) +} diff --git a/internal/util/useragent/useragent_test.go b/internal/util/useragent/useragent_test.go new file mode 100644 index 00000000..392702ea --- /dev/null +++ b/internal/util/useragent/useragent_test.go @@ -0,0 +1,22 @@ +// Copyright (c) Twingate Inc. +// SPDX-License-Identifier: MPL-2.0 + +package useragent + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "gateway/internal/version" +) + +func TestString(t *testing.T) { + original := version.Version + + t.Cleanup(func() { version.Version = original }) + + version.Version = "1.2.3" + + assert.Equal(t, "Twingate-Gateway/1.2.3", String()) +}