diff --git a/.gitignore b/.gitignore index 9b51c53..9e2506e 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,5 @@ examples/testing/ *.toml !examples/*.toml + +test/ diff --git a/cmd/certwarden-deploy/root.go b/cmd/certwarden-deploy/root.go index 4eae9c9..2aa523b 100644 --- a/cmd/certwarden-deploy/root.go +++ b/cmd/certwarden-deploy/root.go @@ -6,15 +6,13 @@ package cmd import ( "os" + "time" "code.lila.network/adoralaura/certwarden-deploy/internal/cli" - "code.lila.network/adoralaura/certwarden-deploy/internal/config" - "code.lila.network/adoralaura/certwarden-deploy/internal/logger" - "github.com/spf13/cobra" + "code.lila.network/adoralaura/certwarden-deploy/internal/configuration" + "github.com/getsentry/sentry-go" ) -var cfgFile string - // Execute adds all child commands to the root command and sets flags appropriately. // This is called by main.main(). It only needs to happen once to the rootCmd. func Execute() { @@ -22,14 +20,14 @@ func Execute() { if err != nil { os.Exit(1) } + defer sentry.Flush(2 * time.Second) } func init() { - cobra.OnInitialize(config.InitializeConfig, logger.InitializeLogger) - cli.RootCmd.PersistentFlags().BoolVarP(&config.VerboseLogging, "verbose", "v", false, "Enable verbose logging") - cli.RootCmd.PersistentFlags().BoolVarP(&config.DryRun, "dry-run", "d", false, "Just show the would-be changes without changing the file system") - cli.RootCmd.PersistentFlags().BoolVarP(&config.QuietLogging, "quiet", "q", false, "Disable any logging (if both -q and -v are set, quiet wins)") - cli.RootCmd.PersistentFlags().StringVarP(&config.ConfigFile, "config", "c", "/etc/certwarden-deploy/config.yaml", "Path to config file (default is /etc/certwarden-deploy/config.yaml)") + cli.RootCmd.PersistentFlags().BoolVarP(&configuration.VerboseLogging, "verbose", "v", false, "Enable verbose logging") + cli.RootCmd.PersistentFlags().BoolVarP(&configuration.DryRun, "dry-run", "d", false, "Just show the would-be changes without changing the file system (turns on verbose logging)") + cli.RootCmd.PersistentFlags().BoolVarP(&configuration.QuietLogging, "quiet", "q", false, "Disable any logging (if both -q and -v are set, quiet wins)") + cli.RootCmd.PersistentFlags().StringVarP(&configuration.ConfigFile, "config", "c", "/etc/certwarden-deploy/config.yaml", "Path to config file (default is /etc/certwarden-deploy/config.yaml)") } diff --git a/go.mod b/go.mod index 3be14ba..3bf302f 100644 --- a/go.mod +++ b/go.mod @@ -8,8 +8,11 @@ require ( ) require ( + github.com/getsentry/sentry-go v0.28.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/sys v0.18.0 // indirect + golang.org/x/text v0.14.0 // indirect gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect ) diff --git a/go.sum b/go.sum index 66ad4e7..56d1597 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/getsentry/sentry-go v0.28.1 h1:zzaSm/vHmGllRM6Tpx1492r0YDzauArdBfkJRtY6P5k= +github.com/getsentry/sentry-go v0.28.1/go.mod h1:1fQZ+7l7eeJ3wYi82q5Hg8GqAPgefRq+FP/QhafYVgg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -14,6 +16,10 @@ github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/certificates/certificates.go b/internal/certificates/certificates.go new file mode 100644 index 0000000..9575485 --- /dev/null +++ b/internal/certificates/certificates.go @@ -0,0 +1,161 @@ +package certificates + +import ( + "bufio" + "crypto/sha256" + "crypto/tls" + "errors" + "fmt" + "io" + "io/fs" + "log/slog" + "net/http" + "os" + "time" + + "code.lila.network/adoralaura/certwarden-deploy/internal/configuration" + "code.lila.network/adoralaura/certwarden-deploy/internal/constants" + "github.com/getsentry/sentry-go" +) + +func HandleCertificates(logger *slog.Logger, config *configuration.ConfigFileData) { + for _, cert := range config.Certificates { + certBytes, err := getCertFromServer( + logger, + cert.Name, + cert.ApiKey, + config.BaseURL, + config.DisableCertificateValidation, + ) + if err != nil { + logger.Error("Failed to get certificate from server", "cert-id", cert.Name, "error", err) + return + } + + certIsDifferent, err := checkCertIsDifferent(logger, cert.FilePath, certBytes) + if err != nil { + logger.Error("failed to handle certificate", "cert-id", cert.Name, "error", err) + return + } + + if certIsDifferent { + err = updateCertOnFS(logger, cert.FilePath, certBytes) + if err != nil { + logger.Error("failed to handle certificate", "cert-id", cert.Name, "error", err) + return + } + } + + logger.Info("Certificate updated successfully", "cert-id", cert.Name) + } +} + +func checkCertIsDifferent(logger *slog.Logger, path string, data []byte) (bool, error) { + filebytes, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return true, nil + } else { + return false, fmt.Errorf("failed to read certificate file on disk: %w", err) + } + } + + existingSha256 := sha256.Sum256(filebytes) + newSha256 := sha256.Sum256(data) + + sumsAreDifferent := existingSha256 != newSha256 + if sumsAreDifferent { + logger.Debug("Certificate on file differs from the certificate on the server", "cert-path", path) + } else { + logger.Debug("Certificate on file is identical to the certificate on the server", "cert-path", path) + } + + return sumsAreDifferent, nil +} + +func updateCertOnFS(logger *slog.Logger, path string, data []byte) error { + if configuration.DryRun { + logger.Debug("DRY-RUN: writing certificate data to file", "cert-path", path) + return nil + } + + file, err := os.Create(path) + if err != nil { + return fmt.Errorf("failed to open certificate for writing: %w", err) + } + + defer func(l *slog.Logger) { + if err := file.Close(); err != nil { + l.Error("failed to close file", "file-path", path, "error", err) + } + }(logger) + + w := bufio.NewWriter(file) + + if _, err := w.Write(data); err != nil { + return fmt.Errorf("failed to write certificate data to file: %w", err) + } + + if err = w.Flush(); err != nil { + return fmt.Errorf("failed to flush certificate data to file: %w", err) + } + + logger.Debug("wrote certificate to file", "file-path", path) + return nil +} + +func getCertFromServer(logger *slog.Logger, certName string, certKey string, baseUrl string, skipInsecure bool) ([]byte, error) { + url := baseUrl + constants.CertificateApiPath + certName + logger.Debug("Certificate request URL: " + url) + var transport http.RoundTripper + + if skipInsecure { + logger.Debug("TLS Certificate Validation is disabled") + transport = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + } else { + logger.Debug("TLS Certificate Validation is enabled") + } + + client := &http.Client{ + Timeout: 10 * time.Second, + Transport: transport, + } + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return []byte{}, fmt.Errorf("failed to prepare to request certificate from server: %w", err) + } + + req.Header.Set("User-Agent", constants.UserAgent) + req.Header.Add(constants.ApiKeyHeaderName, certKey) + + res, err := client.Do(req) + if err != nil { + e := fmt.Errorf("failed to request certificate from server: %w", err) + sentry.CaptureException(e) + return []byte{}, e + } + + defer func(l *slog.Logger) { + if err := res.Body.Close(); err != nil { + l.Error("failed to close http response body", "error", err) + } + }(logger) + + if res.StatusCode == http.StatusUnauthorized { + logger.Error("API-Key for Certificate is invalid, skipping certificate!", "cert-id", certName) + return []byte{}, errors.New("API-Key invalid") + } else if res.StatusCode != http.StatusOK { + logger.Error("failed to get certificate from server", "cert-id", certName, "http-response", res.Status) + } + + body, err := io.ReadAll(res.Body) + if err != nil { + e := fmt.Errorf("failed to read certificate response from server: %w", err) + sentry.CaptureException(e) + return []byte{}, e + } + + return body, nil +} diff --git a/internal/cli/root.go b/internal/cli/root.go index b21433c..b626b2a 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -1,17 +1,41 @@ package cli -import "github.com/spf13/cobra" +import ( + "log/slog" + "os" + + "code.lila.network/adoralaura/certwarden-deploy/internal/certificates" + "code.lila.network/adoralaura/certwarden-deploy/internal/configuration" + "code.lila.network/adoralaura/certwarden-deploy/internal/constants" + "code.lila.network/adoralaura/certwarden-deploy/internal/errlog" + "code.lila.network/adoralaura/certwarden-deploy/internal/logger" + "github.com/spf13/cobra" +) var RootCmd = &cobra.Command{ Use: "certwarden-deploy", - Short: "A brief description of your application", - Long: `A longer description that spans multiple lines and likely contains -examples and usage of using your application. For example: + Short: "Deploy Certificates from CertWarden in a breeze", + Long: `certwarden-deploy is a CLI utility to deploy certificates managed by CertWarden. +Configuration is handled by a single YAML file, so you can get started quickly. -Cobra is a CLI library for Go that empowers applications. -This application is a tool to generate the needed files -to quickly create a Cobra application.`, - // Uncomment the following line if your bare application - // has an action associated with it: - // Run: func(cmd *cobra.Command, args []string) { }, +For more information on how to configure this tool, visit the docs at https://certwarden-deploy.adora.codes`, + Version: constants.Version, + CompletionOptions: cobra.CompletionOptions{DisableDefaultCmd: true}, + Args: cobra.ExactArgs(0), + Run: handleRootCmd, +} + +func handleRootCmd(cmd *cobra.Command, args []string) { + config, err := configuration.InitializeConfig() + if err != nil { + slog.Error("failed to initialize config", "error", err) + os.Exit(1) + } + log := logger.InitializeLogger() + err = errlog.SetupSentry(log, config.Sentry.DSN) + if err != nil { + slog.Error("failed to initialize sentry", "error", err) + } + + certificates.HandleCertificates(log, config) } diff --git a/internal/config/config.go b/internal/config/config.go deleted file mode 100644 index 3416b1c..0000000 --- a/internal/config/config.go +++ /dev/null @@ -1,26 +0,0 @@ -package config - -import ( - "log/slog" - "os" - - "gopkg.in/yaml.v3" -) - -func InitializeConfig() { - if *ConfigFile != "" { - *ConfigFile = "/etc/certwarden-deploy/config.yaml" - } - - data, err := os.ReadFile(*ConfigFile) - if err != nil { - slog.Error("failed to read config file", "file", *ConfigFile, "error", err) - os.Exit(1) - } - - err = yaml.Unmarshal([]byte(data), &Config) - if err != nil { - slog.Error("failed to unmarshal config file", "file", *ConfigFile, "error", err) - os.Exit(1) - } -} diff --git a/internal/configuration/config.go b/internal/configuration/config.go new file mode 100644 index 0000000..9c9703a --- /dev/null +++ b/internal/configuration/config.go @@ -0,0 +1,27 @@ +package configuration + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +func InitializeConfig() (*ConfigFileData, error) { + var cfg ConfigFileData + if ConfigFile == "" { + ConfigFile = "/etc/certwarden-deploy/config.yaml" + } + + data, err := os.ReadFile(ConfigFile) + if err != nil { + return &ConfigFileData{}, fmt.Errorf("failed to read config file: %w", err) + } + + err = yaml.Unmarshal(data, &cfg) + if err != nil { + return &ConfigFileData{}, fmt.Errorf("failed to unmarshal config file: %w", err) + } + + return &cfg, nil +} diff --git a/internal/config/models.go b/internal/configuration/models.go similarity index 76% rename from internal/config/models.go rename to internal/configuration/models.go index c086e65..9c3b5e5 100644 --- a/internal/config/models.go +++ b/internal/configuration/models.go @@ -1,9 +1,9 @@ -package config +package configuration import "log/slog" var Config *ConfigFileData -var ConfigFile *string +var ConfigFile string var Logger *slog.Logger var DryRun bool var QuietLogging bool @@ -12,6 +12,7 @@ var VerboseLogging bool type ConfigFileData struct { BaseURL string `yaml:"base_url"` DisableCertificateValidation bool `yaml:"disable_certificate_validation"` + Sentry SentryData `yaml:"sentry,omitempty"` Certificates []CertificateData `yaml:"certificates"` } @@ -21,3 +22,7 @@ type CertificateData struct { Action string `yaml:"action"` FilePath string `yaml:"file_path"` } + +type SentryData struct { + DSN string `yaml:"dsn"` +} diff --git a/internal/constants/constants.go b/internal/constants/constants.go index e69995a..445f1c4 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -1,7 +1,6 @@ package constants const Version = "0.0.1" -const ChainApiPath = "/certwarden/api/v1/download/certrootchains/" const CertificateApiPath = "/certwarden/api/v1/download/certificates/" const ApiKeyHeaderName = "X-API-Key" const UserAgent = "certwarden-deploy/" + Version + " +code.lila.network/adoralaura/certwarden-deploy" diff --git a/internal/errlog/sentry.go b/internal/errlog/sentry.go new file mode 100644 index 0000000..d380d3b --- /dev/null +++ b/internal/errlog/sentry.go @@ -0,0 +1,27 @@ +package errlog + +import ( + "log/slog" + + "github.com/getsentry/sentry-go" +) + +func SetupSentry(logger *slog.Logger, dsn string) error { + if dsn == "" { + return nil + } + + err := sentry.Init(sentry.ClientOptions{ + Dsn: dsn, + // Set TracesSampleRate to 1.0 to capture 100% + // of transactions for performance monitoring. + // We recommend adjusting this value in production, + TracesSampleRate: 1.0, + }) + if err != nil { + logger.Error("failed to set up sentry") + } + // Flush buffered events before the program terminates. + + return nil +} diff --git a/internal/init/init.go b/internal/init/init.go deleted file mode 100644 index b2c4580..0000000 --- a/internal/init/init.go +++ /dev/null @@ -1,18 +0,0 @@ -package init - -import ( - "code.lila.network/adoralaura/certwarden-deploy/internal/cli" - "code.lila.network/adoralaura/certwarden-deploy/internal/config" - "code.lila.network/adoralaura/certwarden-deploy/internal/logger" - "github.com/spf13/cobra" -) - -func InitializeApp() { - cobra.OnInitialize(config.InitializeConfig, logger.InitializeLogger) - - // Here you will define your flags and configuration settings. - // Cobra supports persistent flags, which, if defined here, - // will be global for your application. - - cli.RootCmd.PersistentFlags().StringVar(config.ConfigFile, "config", "", "config file (default is /etc/certwarden-deploy/config.yaml)") -} diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 28cc7c8..4767184 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -3,25 +3,34 @@ package logger import ( "log/slog" "os" + "strconv" - "code.lila.network/adoralaura/certwarden-deploy/internal/config" + "code.lila.network/adoralaura/certwarden-deploy/internal/configuration" ) -func InitializeLogger() { +func InitializeLogger() *slog.Logger { logLevel := slog.LevelInfo - if config.VerboseLogging { + if configuration.VerboseLogging { logLevel = slog.LevelDebug } - if config.QuietLogging { + if configuration.QuietLogging { logLevel = slog.LevelError } + if configuration.DryRun { + logLevel = slog.LevelDebug + } opts := &slog.HandlerOptions{ Level: logLevel, } handler := slog.NewTextHandler(os.Stdout, opts) + log := slog.New(handler) - slog.SetDefault(slog.New(handler)) + log.Debug("configuration.VerboseLogging is " + strconv.FormatBool(configuration.VerboseLogging)) + log.Debug("configuration.QuietLogging is " + strconv.FormatBool(configuration.QuietLogging)) + log.Debug("configuration.DryRun is " + strconv.FormatBool(configuration.DryRun)) + + return log }