From d0840b2db69cdb5e7b928aa4dec39b65c4e8572d Mon Sep 17 00:00:00 2001 From: Thomas Boerger Date: Thu, 10 Aug 2023 19:18:33 +0200 Subject: [PATCH] feat: make it possible to read secrets from files --- changelog/unreleased/file-secrets.md | 12 +++++ docs/content/usage.md | 22 ++++++++ pkg/action/helper.go | 16 ++++++ pkg/action/server.go | 75 ++++++++++++++++------------ pkg/command/command.go | 4 +- pkg/command/setup.go | 14 ++++-- pkg/config/config.go | 33 ++++++++++++ pkg/store/generic.go | 5 +- 8 files changed, 140 insertions(+), 41 deletions(-) create mode 100644 changelog/unreleased/file-secrets.md create mode 100644 pkg/action/helper.go diff --git a/changelog/unreleased/file-secrets.md b/changelog/unreleased/file-secrets.md new file mode 100644 index 0000000..6150292 --- /dev/null +++ b/changelog/unreleased/file-secrets.md @@ -0,0 +1,12 @@ +Change: Read secrets form files + +We have added proper support to load secrets like the token or the private key +for app authentication from files or from base64-encoded strings. Just provide +the flags or environment variables for token or private key with a DSN formatted +string like `file://path/to/file` or `base64://Zm9vYmFy`. + +Since the private key for GitHub App authentication had been provided in +base64-encoded format this is a breaking change as this won't work anymore until +you prefix the value with `base64://`. + +https://github.com/promhippie/github_exporter/pull/245 diff --git a/docs/content/usage.md b/docs/content/usage.md index 8c2f274..a7acd3a 100644 --- a/docs/content/usage.md +++ b/docs/content/usage.md @@ -98,6 +98,28 @@ directly at [http://localhost:9504/metrics](http://localhost:9504/metrics): - GITHUB_EXPORTER_REPO=promhippie/example {{< / highlight >}} +It's also possible to provide the token to access the GitHub API gets provided +by a file, in case you are using some kind of secret provider. For this use case +you can write the token to a file on any path and reference it with the +following format: + +{{< highlight diff >}} + github-exporter: + image: promhippie/github-exporter:latest + restart: always + environment: +- - GITHUB_EXPORTER_TOKEN=bldyecdtysdahs76ygtbw51w3oeo6a4cvjwoitmb ++ - GITHUB_EXPORTER_TOKEN=file://path/to/secret/file/with/token + - GITHUB_EXPORTER_LOG_PRETTY=true + - GITHUB_EXPORTER_ORG=promhippie + - GITHUB_EXPORTER_REPO=promhippie/example +{{< / highlight >}} + +Besides the `file://` format we currently also support `base64://` which expects +the token in a base64 encoded format. This functionality can be used for the +token and other secret values like the private key for GitHub App authentication +so far. + If you want to collect the metrics of all repositories within an organization you are able to use globbing, but be aware that all repositories matched by globbing won't provide metrics for the number of subscribers, the number of diff --git a/pkg/action/helper.go b/pkg/action/helper.go new file mode 100644 index 0000000..f6fa442 --- /dev/null +++ b/pkg/action/helper.go @@ -0,0 +1,16 @@ +package action + +// boolP returns a boolean pointer. +func boolP(i bool) *bool { + return &i +} + +// stringP returns a string pointer. +func stringP(i string) *string { + return &i +} + +// slceP returns a slice pointer. +func sliceP(i []string) *[]string { + return &i +} diff --git a/pkg/action/server.go b/pkg/action/server.go index ad96207..0384619 100644 --- a/pkg/action/server.go +++ b/pkg/action/server.go @@ -3,7 +3,6 @@ package action import ( "context" "crypto/tls" - "encoding/base64" "io" "net/http" "os" @@ -222,9 +221,23 @@ func handler(cfg *config.Config, db store.Store, logger log.Logger, client *gith if cfg.Collector.Workflows { root.HandleFunc(cfg.Webhook.Path, func(w http.ResponseWriter, r *http.Request) { + secret, err := config.Value(cfg.Webhook.Secret) + + if err != nil { + level.Error(logger).Log( + "msg", "failed to read webhook secret", + "error", err, + ) + + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusInternalServerError) + + io.WriteString(w, http.StatusText(http.StatusInternalServerError)) + } + payload, err := github.ValidatePayload( r, - []byte(cfg.Webhook.Secret), + []byte(secret), ) if err != nil { @@ -297,30 +310,6 @@ func handler(cfg *config.Config, db store.Store, logger log.Logger, client *gith return mux } -func boolP(i bool) *bool { - return &i -} - -func stringP(i string) *string { - return &i -} - -func sliceP(i []string) *[]string { - return &i -} - -func contentOrDecode(file string) ([]byte, error) { - decoded, err := base64.StdEncoding.DecodeString( - file, - ) - - if err != nil { - return os.ReadFile(file) - } - - return decoded, nil -} - func useEnterprise(cfg *config.Config, _ log.Logger) bool { return cfg.Target.BaseURL != "" } @@ -335,7 +324,7 @@ func getClient(cfg *config.Config, logger log.Logger) (*github.Client, error) { } if useApplication(cfg, logger) { - privateKey, err := contentOrDecode(cfg.Target.PrivateKey) + privateKey, err := config.Value(cfg.Target.PrivateKey) if err != nil { level.Error(logger).Log( @@ -350,7 +339,7 @@ func getClient(cfg *config.Config, logger log.Logger) (*github.Client, error) { http.DefaultTransport, cfg.Target.AppID, cfg.Target.InstallID, - privateKey, + []byte(privateKey), ) if err != nil { @@ -369,12 +358,23 @@ func getClient(cfg *config.Config, logger log.Logger) (*github.Client, error) { ), nil } + accessToken, err := config.Value(cfg.Target.Token) + + if err != nil { + level.Error(logger).Log( + "msg", "Failed to read token", + "err", err, + ) + + return nil, err + } + return github.NewClient( oauth2.NewClient( context.Background(), oauth2.StaticTokenSource( &oauth2.Token{ - AccessToken: cfg.Target.Token, + AccessToken: accessToken, }, ), ), @@ -383,7 +383,7 @@ func getClient(cfg *config.Config, logger log.Logger) (*github.Client, error) { func getEnterprise(cfg *config.Config, logger log.Logger) (*github.Client, error) { if useApplication(cfg, logger) { - privateKey, err := contentOrDecode(cfg.Target.PrivateKey) + privateKey, err := config.Value(cfg.Target.PrivateKey) if err != nil { level.Error(logger).Log( @@ -398,7 +398,7 @@ func getEnterprise(cfg *config.Config, logger log.Logger) (*github.Client, error http.DefaultTransport, cfg.Target.AppID, cfg.Target.InstallID, - privateKey, + []byte(privateKey), ) if err != nil { @@ -438,6 +438,17 @@ func getEnterprise(cfg *config.Config, logger log.Logger) (*github.Client, error return client, err } + accessToken, err := config.Value(cfg.Target.Token) + + if err != nil { + level.Error(logger).Log( + "msg", "Failed to read token", + "err", err, + ) + + return nil, err + } + client, err := github.NewClient( oauth2.NewClient( context.WithValue( @@ -453,7 +464,7 @@ func getEnterprise(cfg *config.Config, logger log.Logger) (*github.Client, error ), oauth2.StaticTokenSource( &oauth2.Token{ - AccessToken: cfg.Target.Token, + AccessToken: accessToken, }, ), ), diff --git a/pkg/command/command.go b/pkg/command/command.go index 1518cb5..5e1d484 100644 --- a/pkg/command/command.go +++ b/pkg/command/command.go @@ -192,7 +192,7 @@ func RootFlags(cfg *config.Config) []cli.Flag { &cli.StringFlag{ Name: "github.token", Value: "", - Usage: "Access token for the GitHub API", + Usage: "Access token for the GitHub API, also supports file:// and base64://", EnvVars: []string{"GITHUB_EXPORTER_TOKEN"}, Destination: &cfg.Target.Token, }, @@ -211,7 +211,7 @@ func RootFlags(cfg *config.Config) []cli.Flag { &cli.StringFlag{ Name: "github.private_key", Value: "", - Usage: "Private key for the GitHub app, path or base64-encoded", + Usage: "Private key for the GitHub app, also supports file:// and base64://", EnvVars: []string{"GITHUB_EXPORTER_PRIVATE_KEY"}, Destination: &cfg.Target.PrivateKey, }, diff --git a/pkg/command/setup.go b/pkg/command/setup.go index c3a089f..d599d61 100644 --- a/pkg/command/setup.go +++ b/pkg/command/setup.go @@ -45,7 +45,13 @@ func setupLogger(cfg *config.Config) log.Logger { } func setupStorage(cfg *config.Config, logger log.Logger) (store.Store, error) { - parsed, err := url.Parse(cfg.Database.DSN) + dsn, err := config.Value(cfg.Database.DSN) + + if err != nil { + return nil, fmt.Errorf("failed to read dsn: %w", err) + } + + parsed, err := url.Parse(dsn) if err != nil { return nil, fmt.Errorf("failed to parse dsn: %w", err) @@ -53,11 +59,11 @@ func setupStorage(cfg *config.Config, logger log.Logger) (store.Store, error) { switch parsed.Scheme { case "sqlite", "sqlite3": - return store.NewGenericStore(cfg.Database, logger) + return store.NewGenericStore(dsn, logger) case "mysql", "mariadb": - return store.NewGenericStore(cfg.Database, logger) + return store.NewGenericStore(dsn, logger) case "postgres", "postgresql": - return store.NewGenericStore(cfg.Database, logger) + return store.NewGenericStore(dsn, logger) } return nil, store.ErrUnknownDriver diff --git a/pkg/config/config.go b/pkg/config/config.go index cd89744..20d86cc 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1,6 +1,10 @@ package config import ( + "encoding/base64" + "fmt" + "os" + "strings" "time" "github.com/urfave/cli/v2" @@ -93,3 +97,32 @@ func Labels() *cli.StringSlice { "run", ) } + +// Value returns the config value based on a DSN. +func Value(val string) (string, error) { + if strings.HasPrefix(val, "file://") { + content, err := os.ReadFile( + strings.TrimPrefix(val, "file://"), + ) + + if err != nil { + return "", fmt.Errorf("failed to parse secret file: %w", err) + } + + return string(content), nil + } + + if strings.HasPrefix(val, "base64://") { + content, err := base64.StdEncoding.DecodeString( + strings.TrimPrefix(val, "base64://"), + ) + + if err != nil { + return "", fmt.Errorf("failed to parse base64 value: %w", err) + } + + return string(content), nil + } + + return val, nil +} diff --git a/pkg/store/generic.go b/pkg/store/generic.go index b6e0b46..d1ce7f0 100644 --- a/pkg/store/generic.go +++ b/pkg/store/generic.go @@ -21,7 +21,6 @@ import ( "github.com/golang-migrate/migrate/v4/source" "github.com/golang-migrate/migrate/v4/source/iofs" "github.com/google/go-github/v56/github" - "github.com/promhippie/github_exporter/pkg/config" // Import SQLite driver for database/sql _ "modernc.org/sqlite" @@ -395,8 +394,8 @@ func (s *genericStore) dsn() string { } // NewGenericStore initializes a new generic store. -func NewGenericStore(cfg config.Database, logger log.Logger) (Store, error) { - parsed, err := url.Parse(cfg.DSN) +func NewGenericStore(dsn string, logger log.Logger) (Store, error) { + parsed, err := url.Parse(dsn) if err != nil { return nil, fmt.Errorf("failed to parse dsn: %w", err)