From 8295b7a6cce717539b9445ae69c1f177fbc07916 Mon Sep 17 00:00:00 2001 From: Phil Dibowitz Date: Tue, 18 Aug 2026 16:14:30 -0700 Subject: [PATCH] Update http_config to support cfaccess Adds a new auth type cf-access which allows using this tool with Cloudflare Access. Defines the new auth type, create a new RoundTripper to handle the login / token fetch flow, and set it as the client transport if enabled. I originally planned to add this in prometheus/prometheus for `promtool`, but (1) The implementation is cleaner doing it here and (2) I want to do it for `amtool` as well, so implementing it in `common` means not doing it twice. Signed-off-by: Phil Dibowitz --- config/cfaccess.go | 180 ++++++++++++++++ config/cfaccess_test.go | 204 ++++++++++++++++++ config/http_config.go | 25 ++- config/http_config_test.go | 4 + ...p.conf.cf-access-with-credentials.bad.yaml | 3 + go.mod | 15 +- go.sum | 44 +++- 7 files changed, 466 insertions(+), 9 deletions(-) create mode 100644 config/cfaccess.go create mode 100644 config/cfaccess_test.go create mode 100644 config/testdata/http.conf.cf-access-with-credentials.bad.yaml diff --git a/config/cfaccess.go b/config/cfaccess.go new file mode 100644 index 00000000..d9fb9d67 --- /dev/null +++ b/config/cfaccess.go @@ -0,0 +1,180 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package config + +import ( + "fmt" + "net/http" + "os" + "strings" + "sync" + "time" + + "github.com/cloudflare/cloudflared/token" + "github.com/golang-jwt/jwt/v5" + "github.com/rs/zerolog" +) + +// cfAccessAuthType is the value of Authorization.Type that selects +// Cloudflare Access authentication instead of a literal HTTP Authorization +// scheme. See https://developers.cloudflare.com/cloudflare-one/policies/access/. +const cfAccessAuthType = "cf-access" + +// cfAccessTokenHeader is the header Cloudflare Access checks for a JWT +// obtained through its browser-based login flow. +// +// See https://developers.cloudflare.com/cloudflare-one/tutorials/cli/#curl. +const cfAccessTokenHeader = "Cf-Access-Token" + +// cfAccessTokenExpiryMargin is how long before a cached Cloudflare Access +// token's expiry cfAccessRoundTripper proactively fetches a new one. +// Overridable in tests. +var cfAccessTokenExpiryMargin = 30 * time.Second + +// cfAccessNow stands in for time.Now, overridable in tests so that token +// expiry can be exercised deterministically instead of via real sleeps. +var cfAccessNow = time.Now + +// isCFAccessAuthType reports whether authType selects Cloudflare Access +// authentication. +func isCFAccessAuthType(authType string) bool { + return strings.EqualFold(strings.TrimSpace(authType), cfAccessAuthType) +} + +// cfAccessGetAppInfo and cfAccessFetchToken are indirections over the +// github.com/cloudflare/cloudflared/token package, overridable in tests. +var ( + cfAccessGetAppInfo = token.GetAppInfo + cfAccessFetchToken = token.FetchToken +) + +// cfAccessLogger is shared by all cfAccessRoundTrippers to report the +// progress of interactive Cloudflare Access logins. +var cfAccessLogger = zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: "15:04:05"}).With().Timestamp().Logger() + +// cfAccessApp caches the Cloudflare Access application info and the most +// recently obtained token for a single scheme://host. +type cfAccessApp struct { + mtx sync.Mutex + info *token.AppInfo + token string + expires time.Time +} + +// cfAccessRoundTripper authenticates requests against applications protected +// by Cloudflare Access, by attaching a Cf-Access-Token header obtained +// through cloudflared's login flow. +// +// Unlike a static Authorization header, the target application (and +// therefore its audience) is only known once an actual request is made, so +// the login for each scheme://host seen by this RoundTripper is performed +// lazily, on the first request to it, and cached both in memory and (via +// cloudflared) on disk, until the token is close to expiring. +type cfAccessRoundTripper struct { + next http.RoundTripper + + mtx sync.Mutex + apps map[string]*cfAccessApp +} + +// newCFAccessRoundTripper returns a RoundTripper that authenticates requests +// against Cloudflare Access before forwarding them to next. name identifies +// the calling application in the User-Agent header used while +// authenticating, and in Cloudflare Access's own logs. +func newCFAccessRoundTripper(next http.RoundTripper, name string) http.RoundTripper { + token.Init(name) + return &cfAccessRoundTripper{ + next: next, + apps: make(map[string]*cfAccessApp), + } +} + +// appFor returns the cfAccessApp tracking state for the host targeted by +// req, creating one if this is the first time it has been seen. +func (rt *cfAccessRoundTripper) appFor(key string) *cfAccessApp { + rt.mtx.Lock() + defer rt.mtx.Unlock() + + app, ok := rt.apps[key] + if !ok { + app = &cfAccessApp{} + rt.apps[key] = app + } + return app +} + +// RoundTrip implements http.RoundTripper. +func (rt *cfAccessRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + key := req.URL.Scheme + "://" + req.URL.Host + app := rt.appFor(key) + + tok, err := app.fetch(req) + if err != nil { + return nil, fmt.Errorf("cloudflare access: %w", err) + } + + req.Header.Set(cfAccessTokenHeader, tok) + return rt.next.RoundTrip(req) +} + +// fetch returns a valid Cloudflare Access token for the application behind +// req.URL, fetching or refreshing it as needed. It may block on an +// interactive browser login if no valid cached token is available, either +// in memory or in cloudflared's own on-disk token cache. +func (a *cfAccessApp) fetch(req *http.Request) (string, error) { + a.mtx.Lock() + defer a.mtx.Unlock() + + if a.token != "" && cfAccessNow().Add(cfAccessTokenExpiryMargin).Before(a.expires) { + return a.token, nil + } + + if a.info == nil { + info, err := cfAccessGetAppInfo(req.URL) + if err != nil { + return "", fmt.Errorf("failed to detect Cloudflare Access application for %s://%s: %w", req.URL.Scheme, req.URL.Host, err) + } + a.info = info + } + + tok, err := cfAccessFetchToken(req.URL, a.info, false, false, &cfAccessLogger) + if err != nil { + return "", fmt.Errorf("failed to fetch Cloudflare Access token: %w", err) + } + + a.token = tok + a.expires = cfAccessTokenExpiry(tok) + return tok, nil +} + +// cfAccessTokenExpiry returns the expiry time encoded in the "exp" claim of +// tok, or the zero time if it cannot be determined. tok is not signature +// verified: it was just obtained directly from cloudflared over an +// authenticated channel, so verification against Cloudflare's public keys +// would add complexity without a meaningful security benefit here. A zero +// return value simply means the token will be treated as already expired, +// and a new one fetched (which, thanks to cloudflared's own on-disk cache, +// is cheap and does not by itself trigger a new interactive login) on the +// next request. +func cfAccessTokenExpiry(tok string) time.Time { + claims := jwt.MapClaims{} + if _, _, err := jwt.NewParser().ParseUnverified(tok, claims); err != nil { + return time.Time{} + } + exp, err := claims.GetExpirationTime() + if err != nil || exp == nil { + return time.Time{} + } + return exp.Time +} diff --git a/config/cfaccess_test.go b/config/cfaccess_test.go new file mode 100644 index 00000000..34523e5d --- /dev/null +++ b/config/cfaccess_test.go @@ -0,0 +1,204 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package config + +import ( + "errors" + "net/http" + "net/url" + "sync/atomic" + "testing" + "time" + + "github.com/cloudflare/cloudflared/token" + "github.com/golang-jwt/jwt/v5" + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" +) + +// signedTestToken returns a JWT with the given expiry encoded in its "exp" +// claim. cfAccessTokenExpiry does not verify the signature, so the signing +// key is arbitrary. +func signedTestToken(t *testing.T, expiry time.Time) string { + t.Helper() + tok := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "exp": jwt.NewNumericDate(expiry), + }) + signed, err := tok.SignedString([]byte("test-signing-key")) + require.NoError(t, err) + return signed +} + +func TestCFAccessTokenExpiry(t *testing.T) { + t.Run("valid token", func(t *testing.T) { + expiry := time.Now().Add(time.Hour).Truncate(time.Second) + got := cfAccessTokenExpiry(signedTestToken(t, expiry)) + require.WithinDuration(t, expiry, got, time.Second) + }) + + t.Run("malformed token", func(t *testing.T) { + require.True(t, cfAccessTokenExpiry("not-a-jwt").IsZero()) + }) + + t.Run("token without exp claim", func(t *testing.T) { + tok := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{}) + signed, err := tok.SignedString([]byte("test-signing-key")) + require.NoError(t, err) + require.True(t, cfAccessTokenExpiry(signed).IsZero()) + }) +} + +func TestIsCFAccessAuthType(t *testing.T) { + for _, tc := range []struct { + authType string + want bool + }{ + {"cf-access", true}, + {"CF-Access", true}, + {" cf-access ", true}, + {"Bearer", false}, + {"", false}, + } { + require.Equalf(t, tc.want, isCFAccessAuthType(tc.authType), "authType=%q", tc.authType) + } +} + +// withFakeCFAccess overrides cfAccessGetAppInfo and cfAccessFetchToken for +// the duration of the test, restoring the real cloudflared-backed +// implementations afterwards. +func withFakeCFAccess( + t *testing.T, + getAppInfo func(reqURL *url.URL) (*token.AppInfo, error), + fetchToken func(appURL *url.URL, appInfo *token.AppInfo) (string, error), +) { + t.Helper() + + origGetAppInfo := cfAccessGetAppInfo + origFetchToken := cfAccessFetchToken + t.Cleanup(func() { + cfAccessGetAppInfo = origGetAppInfo + cfAccessFetchToken = origFetchToken + }) + + cfAccessGetAppInfo = getAppInfo + cfAccessFetchToken = func(appURL *url.URL, appInfo *token.AppInfo, _, _ bool, _ *zerolog.Logger) (string, error) { + return fetchToken(appURL, appInfo) + } +} + +func TestCFAccessRoundTripper(t *testing.T) { + fakeNow := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + origNow := cfAccessNow + cfAccessNow = func() time.Time { return fakeNow } + t.Cleanup(func() { cfAccessNow = origNow }) + + origMargin := cfAccessTokenExpiryMargin + cfAccessTokenExpiryMargin = 30 * time.Second + t.Cleanup(func() { cfAccessTokenExpiryMargin = origMargin }) + + var ( + getAppInfoCalls atomic.Int32 + fetchTokenCalls atomic.Int32 + ) + + shortLivedToken := signedTestToken(t, fakeNow.Add(time.Minute)) + longLivedToken := signedTestToken(t, fakeNow.Add(time.Hour)) + + withFakeCFAccess(t, + func(reqURL *url.URL) (*token.AppInfo, error) { + getAppInfoCalls.Add(1) + return &token.AppInfo{AuthDomain: "auth." + reqURL.Host, AppAUD: "aud", AppDomain: reqURL.Host}, nil + }, + func(*url.URL, *token.AppInfo) (string, error) { + n := fetchTokenCalls.Add(1) + if n == 1 { + return shortLivedToken, nil + } + return longLivedToken, nil + }, + ) + + var gotHeader string + next := NewRoundTripCheckRequest(func(req *http.Request) { + gotHeader = req.Header.Get(cfAccessTokenHeader) + }, &http.Response{StatusCode: http.StatusOK}, nil) + + rt := newCFAccessRoundTripper(next, "test") + + req1, err := http.NewRequest(http.MethodGet, "https://app.example.com/query", http.NoBody) + require.NoError(t, err) + _, err = rt.RoundTrip(req1) + require.NoError(t, err) + require.Equal(t, shortLivedToken, gotHeader) + require.EqualValues(t, 1, getAppInfoCalls.Load()) + require.EqualValues(t, 1, fetchTokenCalls.Load()) + + // A second request to the same host, while the cached token is still + // valid, must not re-fetch anything. + req2, err := http.NewRequest(http.MethodGet, "https://app.example.com/query", http.NoBody) + require.NoError(t, err) + _, err = rt.RoundTrip(req2) + require.NoError(t, err) + require.Equal(t, shortLivedToken, gotHeader) + require.EqualValues(t, 1, getAppInfoCalls.Load()) + require.EqualValues(t, 1, fetchTokenCalls.Load()) + + // A request to a different host must fetch a fresh token, independent + // of the first host's cached state. + req3, err := http.NewRequest(http.MethodGet, "https://other.example.com/query", http.NoBody) + require.NoError(t, err) + _, err = rt.RoundTrip(req3) + require.NoError(t, err) + require.Equal(t, longLivedToken, gotHeader) + require.EqualValues(t, 2, getAppInfoCalls.Load()) + require.EqualValues(t, 2, fetchTokenCalls.Load()) + + // Advancing the clock past the short-lived token's expiry margin must + // trigger a refetch for the original host, reusing the already-known + // AppInfo. + fakeNow = fakeNow.Add(time.Minute) + req4, err := http.NewRequest(http.MethodGet, "https://app.example.com/query", http.NoBody) + require.NoError(t, err) + _, err = rt.RoundTrip(req4) + require.NoError(t, err) + require.Equal(t, longLivedToken, gotHeader) + require.EqualValuesf(t, 2, getAppInfoCalls.Load(), "AppInfo should be cached across token refreshes") + require.EqualValues(t, 3, fetchTokenCalls.Load()) +} + +var errFakeGetAppInfo = errors.New("fake GetAppInfo failure") + +func TestCFAccessRoundTripperGetAppInfoError(t *testing.T) { + withFakeCFAccess(t, + func(*url.URL) (*token.AppInfo, error) { + return nil, errFakeGetAppInfo + }, + func(*url.URL, *token.AppInfo) (string, error) { + t.Fatal("FetchToken must not be called when GetAppInfo fails") + return "", nil + }, + ) + + next := NewRoundTripCheckRequest(func(*http.Request) { + t.Fatal("next RoundTripper must not be called when authentication fails") + }, nil, nil) + + rt := newCFAccessRoundTripper(next, "test") + req, err := http.NewRequest(http.MethodGet, "https://app.example.com/query", http.NoBody) + require.NoError(t, err) + + _, err = rt.RoundTrip(req) + require.Error(t, err) + require.ErrorIs(t, err, errFakeGetAppInfo) +} diff --git a/config/http_config.go b/config/http_config.go index d633479c..a9b6e7e9 100644 --- a/config/http_config.go +++ b/config/http_config.go @@ -158,6 +158,11 @@ func (a *BasicAuth) SetDirectory(dir string) { // Authorization contains HTTP authorization credentials. type Authorization struct { + // Type sets the scheme used for the Authorization header, for example + // "Bearer" (the default). As a special case, setting Type to "cf-access" + // authenticates against Cloudflare Access instead of sending a literal + // Authorization header: Credentials, CredentialsFile and CredentialsRef + // must be left unset in that case. Type string `yaml:"type,omitempty" json:"type,omitempty"` Credentials Secret `yaml:"credentials,omitempty" json:"credentials,omitempty"` CredentialsFile string `yaml:"credentials_file,omitempty" json:"credentials_file,omitempty"` @@ -416,6 +421,9 @@ func (c *HTTPClientConfig) Validate() error { if strings.ToLower(c.Authorization.Type) == "basic" { return errors.New(`authorization type cannot be set to "basic", use "basic_auth" instead`) } + if isCFAccessAuthType(c.Authorization.Type) && nonZeroCount(string(c.Authorization.Credentials) != "", c.Authorization.CredentialsFile != "", c.Authorization.CredentialsRef != "") > 0 { + return fmt.Errorf("authorization credentials, credentials_file & credentials_ref must not be configured when authorization type is %q", cfAccessAuthType) + } if c.BasicAuth != nil || c.OAuth2 != nil { return errors.New("at most one of basic_auth, oauth2 & authorization must be configured") } @@ -671,13 +679,20 @@ func NewRoundTripperFromConfigWithContext(ctx context.Context, cfg HTTPClientCon } // If a authorization_credentials is provided, create a round tripper that will set the - // Authorization header correctly on each request. + // Authorization header correctly on each request. authorization.type: cf-access is a + // special case: it is not a real HTTP Authorization scheme, so instead of setting an + // Authorization header it authenticates against Cloudflare Access and attaches the + // resulting token as a Cf-Access-Token header. if cfg.Authorization != nil { - credentialsSecret, err := toSecret(opts.secretManager, cfg.Authorization.Credentials, cfg.Authorization.CredentialsFile, cfg.Authorization.CredentialsRef) - if err != nil { - return nil, fmt.Errorf("unable to use credentials: %w", err) + if isCFAccessAuthType(cfg.Authorization.Type) { + rt = newCFAccessRoundTripper(rt, name) + } else { + credentialsSecret, err := toSecret(opts.secretManager, cfg.Authorization.Credentials, cfg.Authorization.CredentialsFile, cfg.Authorization.CredentialsRef) + if err != nil { + return nil, fmt.Errorf("unable to use credentials: %w", err) + } + rt = NewAuthorizationCredentialsRoundTripper(cfg.Authorization.Type, credentialsSecret, rt) } - rt = NewAuthorizationCredentialsRoundTripper(cfg.Authorization.Type, credentialsSecret, rt) } // Backwards compatibility, be nice with importers who would not have // called Validate(). diff --git a/config/http_config_test.go b/config/http_config_test.go index 4d61ae12..1c8b39b2 100644 --- a/config/http_config_test.go +++ b/config/http_config_test.go @@ -109,6 +109,10 @@ var invalidHTTPClientConfigs = []struct { httpClientConfigFile: "testdata/http.conf.auth-creds-no-basic.bad.yaml", errMsg: `authorization type cannot be set to "basic", use "basic_auth" instead`, }, + { + httpClientConfigFile: "testdata/http.conf.cf-access-with-credentials.bad.yaml", + errMsg: `authorization credentials, credentials_file & credentials_ref must not be configured when authorization type is "cf-access"`, + }, { httpClientConfigFile: "testdata/http.conf.oauth2-secret-and-file-set.bad.yml", errMsg: "at most one of oauth2 client_secret, client_secret_file & client_secret_ref must be configured", diff --git a/config/testdata/http.conf.cf-access-with-credentials.bad.yaml b/config/testdata/http.conf.cf-access-with-credentials.bad.yaml new file mode 100644 index 00000000..2fb1c942 --- /dev/null +++ b/config/testdata/http.conf.cf-access-with-credentials.bad.yaml @@ -0,0 +1,3 @@ +authorization: + type: cf-access + credentials: shouldnotbeset diff --git a/go.mod b/go.mod index b491069f..de730327 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.0 require ( github.com/alecthomas/kingpin/v2 v2.4.0 + github.com/cloudflare/cloudflared v0.0.0-20260306125340-d2a87e9b9345 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 @@ -11,6 +12,7 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f github.com/prometheus/client_model v0.6.2 + github.com/rs/zerolog v1.20.0 github.com/stretchr/testify v1.11.1 go.yaml.in/yaml/v2 v2.4.4 golang.org/x/net v0.57.0 @@ -22,12 +24,21 @@ require ( github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/coreos/go-oidc/v3 v3.17.0 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/fsnotify/fsnotify v1.4.9 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/jpillora/backoff v1.0.0 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/procfs v0.21.0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/urfave/cli/v2 v2.3.0 // indirect github.com/xhit/go-str2duration/v2 v2.1.0 // indirect + golang.org/x/crypto v0.54.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.40.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index a266e807..73af4ae7 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,4 @@ +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY= github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0= @@ -6,9 +7,22 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudflare/cloudflared v0.0.0-20260306125340-d2a87e9b9345 h1:nKSn6yOXQY4IY0XMO7sP6OH6VKNZf/tIkz9gCNglkHk= +github.com/cloudflare/cloudflared v0.0.0-20260306125340-d2a87e9b9345/go.mod h1:Wa0JJ6XKazYtLNa6RHFMiVG1Px2AcLP/mkjD6ASMIY8= +github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= +github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM= +github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -19,18 +33,31 @@ github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2E github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/procfs v0.21.0 h1:Qh/e6TlBjZf+XLLqNCqFGmCU6Kj/2Bu7kj3oAc0UnXc= github.com/prometheus/procfs v0.21.0/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= +github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= +github.com/rs/zerolog v1.20.0 h1:38k9hgtUBdxFwE34yS8rTHmHBa4eN16E4DJlv177LNs= +github.com/rs/zerolog v1.20.0/go.mod h1:IzD0RJ65iWH0w97OQQebJEvTZYvsCUm9WVLWBQrJRjo= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -41,24 +68,37 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M= +github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.0.0-20190828213141-aed303cbaa74/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=