Compare commits

..

5 commits

12 changed files with 303 additions and 143 deletions

View file

@ -8,6 +8,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.2.0] - 2024-07-11
### ⚠️ Breaking Changes
- Config file syntax was changed to accomodate both private and public key for certificates.
This change is __NOT__ backwards compatible!
The following yaml keys were changed/added:
- `api_key`: changed to `cert_secret`
- `file_path`: changed to `cert_path`
- added keys: `key_secret`, `key_path`
### Changed
- config file syntax to accomodate private keys too
- refactor code
## [0.1.1] - 2024-07-03 ## [0.1.1] - 2024-07-03
@ -21,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- some documentation - some documentation
[unreleased]: https://code.lila.network/adoralaura/certwarden-deploy/compare/0.1.1...HEAD [unreleased]: https://code.lila.network/adoralaura/certwarden-deploy/compare/0.2.0...HEAD
[0.2.0]: https://code.lila.network/adoralaura/certwarden-deploy/compare/0.1.1...0.2.0
[0.1.1]: https://code.lila.network/adoralaura/certwarden-deploy/compare/0.1.0...0.1.1 [0.1.1]: https://code.lila.network/adoralaura/certwarden-deploy/compare/0.1.0...0.1.1
[0.1.0]: https://code.lila.network/adoralaura/certwarden-deploy/releases/tag/0.1.0 [0.1.0]: https://code.lila.network/adoralaura/certwarden-deploy/releases/tag/0.1.0

View file

@ -17,126 +17,187 @@ import (
"code.lila.network/adoralaura/certwarden-deploy/internal/configuration" "code.lila.network/adoralaura/certwarden-deploy/internal/configuration"
"code.lila.network/adoralaura/certwarden-deploy/internal/constants" "code.lila.network/adoralaura/certwarden-deploy/internal/constants"
"github.com/getsentry/sentry-go"
) )
func HandleCertificates(logger *slog.Logger, config *configuration.ConfigFileData) { func HandleCertificates(logger *slog.Logger, config *configuration.ConfigFileData) {
for _, cert := range config.Certificates { for _, cert := range config.Certificates {
certBytes, err := getCertFromServer( certInfos := GenericCertificate{
logger, Name: cert.Name,
cert.Name, FilePath: cert.CertificatePath,
cert.ApiKey, Secret: cert.CertificateSecret,
config.BaseURL, IsKey: false,
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) keyInfos := GenericCertificate{
if err != nil { Name: cert.Name,
logger.Error("failed to handle certificate", "cert-id", cert.Name, "error", err) FilePath: cert.KeyPath,
return Secret: cert.KeySecret,
IsKey: true,
} }
if certIsDifferent || configuration.Force { // Rollout Certificate
if configuration.Force { certOnDiskChanged, err := certInfos.Rollout(logger, config.BaseURL, config.DisableCertificateValidation)
logger.Info("Forcing file system change due to --force", "cert-id", cert.Name) if err != nil {
} logger.Error(
"Failed to roll out Certificate", "path",
certInfos.FilePath, "name", cert.Name, "error", err,
)
continue
}
err = updateCertOnFS(logger, cert.FilePath, certBytes) // Rollout Key
if err != nil { keyOnDiskChanged, err := keyInfos.Rollout(logger, config.BaseURL, config.DisableCertificateValidation)
logger.Error("failed to handle certificate", "cert-id", cert.Name, "error", err) if err != nil {
return logger.Error(
} "Failed to roll out Key", "path",
keyInfos.FilePath, "name", cert.Name, "error", err,
)
continue
}
// if cert OR key changed OR --force
if (certOnDiskChanged || keyOnDiskChanged) || configuration.Force {
if configuration.Force { if configuration.Force {
logger.Info("Forcing file system change due to --force", "cert-id", cert.Name) logger.Info("Forcing file system change due to --force", "name", cert.Name)
} }
err = handleCertificateAction(cert) err = handleCertificateAction(cert.Action)
if err != nil { if err != nil {
logger.Error("post certificate change command failed", "cert-id", cert.Name, "error", err) logger.Error("Failed to execute post-rollout action", "name", cert.Name, "error", err)
} }
} }
if certIsDifferent {
logger.Info("New certificate rolled out", "cert-id", cert.Name)
} else {
logger.Info("Certificate not changed, skipping...", "cert-id", cert.Name)
}
} }
} }
func getCertFromFile(path string) ([]byte, error) { // Rollout handles getting the certificate/key data from the
filebytes, err := os.ReadFile(path) // server and writing it to disk if the data differs.
//
// Returns error on error, true if certificate action needs to be executed, false if not
func (c *GenericCertificate) Rollout(logger *slog.Logger, baseUrl string, skipInsecure bool) (bool, error) {
err := c.fetchFromServer(
logger,
baseUrl,
skipInsecure,
)
if err != nil {
return false, fmt.Errorf("failed to get certificate from server: %w", err)
}
fileNeedsRollout, err := c.needsRollout(logger)
if err != nil {
return false, fmt.Errorf("failed to check certificate on disk: %w", err)
}
if fileNeedsRollout || configuration.Force {
if configuration.Force {
logger.Info("Forcing file system change due to --force", "name", c.Name)
}
err = c.writeToDisk(logger)
if err != nil {
return false, fmt.Errorf("failed to handle certificate: %w", err)
}
}
if fileNeedsRollout {
logger.Info("New file deployed", "path", c.FilePath)
return true, nil
} else {
logger.Info("File not changed, skipping...", "path", c.FilePath)
return false, nil
}
}
// readFromDisk reads file data from disk and populates the data []byte field.
//
// Returns error or nil on success
func (c *GenericCertificate) readFromDisk() error {
filebytes, err := os.ReadFile(c.FilePath)
if err != nil { if err != nil {
if errors.Is(err, fs.ErrNotExist) { if errors.Is(err, fs.ErrNotExist) {
return []byte{}, err return err
} else { } else {
return []byte{}, fmt.Errorf("failed to read certificate file on disk: %w", err) return fmt.Errorf("failed to read file from disk: %w", err)
} }
} }
return filebytes, nil
c.diskBytes = filebytes
return nil
} }
func checkCertIsDifferent(logger *slog.Logger, path string, data []byte) (bool, error) { // needsRollout checks the data []bytes against the data on disk.
filebytes, err := getCertFromFile(path) //
// Returns true if file needs rollout, false if not
func (c *GenericCertificate) needsRollout(logger *slog.Logger) (bool, error) {
err := c.readFromDisk()
if err != nil { if err != nil {
if errors.Is(err, fs.ErrNotExist) { if errors.Is(err, fs.ErrNotExist) {
return true, nil return true, nil
} else { } else {
return false, fmt.Errorf("failed to compare certificates: %w", err) return false, fmt.Errorf("failed to compare data to file on disk: %w", err)
} }
} }
existingSha256 := sha256.Sum256(filebytes) diskHash := sha256.Sum256(c.diskBytes)
newSha256 := sha256.Sum256(data) serverHash := sha256.Sum256(c.serverBytes)
sumsAreDifferent := existingSha256 != newSha256 hashesAreDifferent := diskHash != serverHash
if sumsAreDifferent { if hashesAreDifferent {
logger.Debug("Certificate on file differs from the certificate on the server", "cert-path", path) logger.Debug("File on disk differs from server source", "path", c.FilePath)
} else { } else {
logger.Debug("Certificate on file is identical to the certificate on the server", "cert-path", path) logger.Debug("File on disk is identical to server source", "path", c.FilePath)
} }
return sumsAreDifferent, nil return hashesAreDifferent, nil
} }
func updateCertOnFS(logger *slog.Logger, path string, data []byte) error { // writeToDisk flushes the certificate data to disk.
//
// Returns error or nil on success.
func (c *GenericCertificate) writeToDisk(logger *slog.Logger) error {
if configuration.DryRun { if configuration.DryRun {
logger.Debug("DRY-RUN: writing certificate data to file", "cert-path", path) logger.Debug("DRY-RUN: writing data to file", "path", c.FilePath)
return nil return nil
} }
file, err := os.Create(path) file, err := os.Create(c.FilePath)
if err != nil { if err != nil {
return fmt.Errorf("failed to open certificate for writing: %w", err) return fmt.Errorf("failed to open file for writing: %w", err)
} }
defer func(l *slog.Logger) { defer func(l *slog.Logger) {
if err := file.Close(); err != nil { if err := file.Close(); err != nil {
l.Error("failed to close file", "file-path", path, "error", err) l.Error("failed to close file", "path", c.FilePath, "error", err)
} }
}(logger) }(logger)
w := bufio.NewWriter(file) w := bufio.NewWriter(file)
if _, err := w.Write(data); err != nil { if _, err := w.Write(c.serverBytes); err != nil {
return fmt.Errorf("failed to write certificate data to file: %w", err) return fmt.Errorf("failed to write data to file: %w", err)
} }
if err = w.Flush(); err != nil { if err = w.Flush(); err != nil {
return fmt.Errorf("failed to flush certificate data to file: %w", err) return fmt.Errorf("failed to flush data to file: %w", err)
} }
logger.Debug("wrote certificate to file", "file-path", path) logger.Debug("Successfully wrote to file", "path", c.FilePath)
return nil return nil
} }
func getCertFromServer(logger *slog.Logger, certName string, certKey string, baseUrl string, skipInsecure bool) ([]byte, error) { // fetchFromServer fetches the cert/key data from the CertWarden server and
url := baseUrl + constants.CertificateApiPath + certName // fills the serverBytes field.
//
// Returns error or nil on success.
func (c *GenericCertificate) fetchFromServer(logger *slog.Logger, baseUrl string, skipInsecure bool) error {
var url string
if c.IsKey {
url = baseUrl + constants.CertificateApiPath + c.Name
} else {
url = baseUrl + constants.CertificateApiPath + c.Name
}
logger.Debug("Certificate request URL: " + url) logger.Debug("Certificate request URL: " + url)
var transport http.RoundTripper var transport http.RoundTripper
@ -155,17 +216,15 @@ func getCertFromServer(logger *slog.Logger, certName string, certKey string, bas
} }
req, err := http.NewRequest("GET", url, nil) req, err := http.NewRequest("GET", url, nil)
if err != nil { if err != nil {
return []byte{}, fmt.Errorf("failed to prepare to request certificate from server: %w", err) return fmt.Errorf("failed to prepare to request certificate from server: %w", err)
} }
req.Header.Set("User-Agent", constants.UserAgent) req.Header.Set("User-Agent", constants.UserAgent)
req.Header.Add(constants.ApiKeyHeaderName, certKey) req.Header.Add(constants.ApiKeyHeaderName, c.Secret)
res, err := client.Do(req) res, err := client.Do(req)
if err != nil { if err != nil {
e := fmt.Errorf("failed to request certificate from server: %w", err) return fmt.Errorf("failed to request certificate from server: %w", err)
sentry.CaptureException(e)
return []byte{}, e
} }
defer func(l *slog.Logger) { defer func(l *slog.Logger) {
@ -175,30 +234,27 @@ func getCertFromServer(logger *slog.Logger, certName string, certKey string, bas
}(logger) }(logger)
if res.StatusCode == http.StatusUnauthorized { if res.StatusCode == http.StatusUnauthorized {
logger.Error("API-Key for Certificate is invalid, skipping certificate!", "cert-id", certName) logger.Error("API-Key for Certificate is invalid, skipping certificate!", "name", c.Name)
return []byte{}, errors.New("API-Key invalid") return errors.New("API-Key invalid")
} else if res.StatusCode != http.StatusOK { } else if res.StatusCode != http.StatusOK {
logger.Error("failed to get certificate from server", "cert-id", certName, "http-response", res.Status) logger.Error("failed to get certificate from server", "name", c.Name, "http-response", res.Status)
} }
body, err := io.ReadAll(res.Body) bodyBytes, err := io.ReadAll(res.Body)
if err != nil { if err != nil {
e := fmt.Errorf("failed to read certificate response from server: %w", err) return fmt.Errorf("failed to read certificate response from server: %w", err)
sentry.CaptureException(e)
return []byte{}, e
} }
return body, nil c.serverBytes = bodyBytes
return nil
} }
func handleCertificateAction(cert configuration.CertificateData) error { // handleCertificateAction executes the user-defined action after successful certificate deployment
if cert.Action == "" { func handleCertificateAction(action string) error {
if action == "" {
return nil return nil
} }
action := strings.ReplaceAll(cert.Action, "{name}", cert.Name)
action = strings.ReplaceAll(action, "{path}", cert.FilePath)
sargs := strings.Split(action, " ") sargs := strings.Split(action, " ")
cmd := exec.Command(sargs[0], sargs[1:]...) cmd := exec.Command(sargs[0], sargs[1:]...)

View file

@ -1 +0,0 @@
package certificates

View file

@ -0,0 +1,18 @@
package certificates
// GenericCertificate is a generic container to enable us to
// handle both certificates and keys with one function
type GenericCertificate struct {
Name string
FilePath string
Secret string
// True if key, false if certificate
IsKey bool
// Bytes fetched from the server
serverBytes []byte
// Bytes fetched from disk
diskBytes []byte
}

View file

@ -7,7 +7,6 @@ import (
"code.lila.network/adoralaura/certwarden-deploy/internal/certificates" "code.lila.network/adoralaura/certwarden-deploy/internal/certificates"
"code.lila.network/adoralaura/certwarden-deploy/internal/configuration" "code.lila.network/adoralaura/certwarden-deploy/internal/configuration"
"code.lila.network/adoralaura/certwarden-deploy/internal/constants" "code.lila.network/adoralaura/certwarden-deploy/internal/constants"
"code.lila.network/adoralaura/certwarden-deploy/internal/errlog"
"code.lila.network/adoralaura/certwarden-deploy/internal/logger" "code.lila.network/adoralaura/certwarden-deploy/internal/logger"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@ -32,12 +31,14 @@ func handleRootCmd(cmd *cobra.Command, args []string) {
os.Exit(1) os.Exit(1)
} }
log := logger.InitializeLogger() log := logger.InitializeLogger()
err = errlog.SetupSentry(log, config.Sentry.DSN) config.SubstituteKeys(log)
if err != nil {
slog.Error("failed to initialize sentry", "error", err)
}
configuration.ValidateConfig(log, *config) validation := config.IsValid()
if validation.HasMessages() {
validation.Print(log)
slog.Error("The configuration file has errors! Application cannot start unless all errors are corrected!")
panic(1)
}
certificates.HandleCertificates(log, config) certificates.HandleCertificates(log, config)
} }

View file

@ -1,5 +1,7 @@
package configuration package configuration
import "log/slog"
// Config file gets read into here // Config file gets read into here
var Config *ConfigFileData var Config *ConfigFileData
@ -28,12 +30,36 @@ type ConfigFileData struct {
// Struct that holds the details of a single managed certificate // Struct that holds the details of a single managed certificate
type CertificateData struct { type CertificateData struct {
Name string `yaml:"name"` Name string `yaml:"name"`
ApiKey string `yaml:"api_key"` CertificateSecret string `yaml:"cert_secret"`
Action string `yaml:"action"` CertificatePath string `yaml:"cert_path"`
FilePath string `yaml:"file_path"` KeySecret string `yaml:"key_secret"`
KeyPath string `yaml:"key_path"`
Action string `yaml:"action"`
} }
type SentryData struct { type SentryData struct {
DSN string `yaml:"dsn"` DSN string `yaml:"dsn"`
} }
type ConfigValidationError struct {
ErrorMessages []string
}
func (e *ConfigValidationError) Error() string {
return "Configuration file has errors! Application cannot start unless the errors are corrected."
}
func (e *ConfigValidationError) Add(msg string) {
e.ErrorMessages = append(e.ErrorMessages, msg)
}
func (e *ConfigValidationError) HasMessages() bool {
return len(e.ErrorMessages) == 0
}
func (e *ConfigValidationError) Print(logger *slog.Logger) {
for _, line := range e.ErrorMessages {
logger.Error(line)
}
}

View file

@ -0,0 +1,17 @@
package configuration
import (
"log/slog"
"strings"
)
func (c *ConfigFileData) SubstituteKeys(logger *slog.Logger) {
for index, cert := range c.Certificates {
c.Certificates[index].CertificatePath = strings.ReplaceAll(cert.CertificatePath, "{name}", c.Certificates[index].Name)
c.Certificates[index].KeyPath = strings.ReplaceAll(cert.KeyPath, "{name}", c.Certificates[index].Name)
c.Certificates[index].Action = strings.ReplaceAll(cert.Action, "{name}", c.Certificates[index].Name)
c.Certificates[index].Action = strings.ReplaceAll(c.Certificates[index].Action, "{cert_path}", c.Certificates[index].CertificatePath)
c.Certificates[index].Action = strings.ReplaceAll(c.Certificates[index].Action, "{key_path}", c.Certificates[index].KeyPath)
}
}

View file

@ -0,0 +1,65 @@
package configuration
import (
"testing"
)
// TestStringSubstitutionWithPlaceholders tests the string substitution feature.
// It ensures that {name}, {cert_path} and {key_path} get substituted correctly.
func TestStringSubstitutionWithPlaceholders(t *testing.T) {
cert := CertificateData{
Name: "qwer",
CertificatePath: "/fake/path/{name}",
KeyPath: "/fake/path/{name}-key",
Action: "./fake action {cert_path} {key_path}",
}
cfg := ConfigFileData{
Certificates: []CertificateData{cert},
}
cfg.SubstituteKeys(nil)
if cfg.Certificates[0].CertificatePath != "/fake/path/qwer" {
t.Fail()
t.Logf(`CertificatePath = %q, want "/fake/path/qwer"`, cfg.Certificates[0].CertificatePath)
}
if cfg.Certificates[0].KeyPath != "/fake/path/qwer-key" {
t.Fail()
t.Logf(`KeyPath = %q, want "/fake/path/qwer-key"`, cfg.Certificates[0].KeyPath)
}
if cfg.Certificates[0].Action != "./fake action /fake/path/qwer /fake/path/qwer-key" {
t.Fail()
t.Logf(`Action = %q, want "./fake action /fake/path/qwer /fake/path/qwer-key"`, cfg.Certificates[0].Action)
}
}
// TestStringSubstitutionWithPlaceholders tests the string substitution feature.
// It ensures that if no substitutes are present, the config values are not changed.
func TestStringSubstitutionWithoutPlaceholders(t *testing.T) {
cert := CertificateData{
Name: "qwer",
CertificatePath: "/fake/path/asd",
KeyPath: "/fake/path/asdf-key",
Action: "./fake action abcd efgh",
}
cfg := ConfigFileData{
Certificates: []CertificateData{cert},
}
cfg.SubstituteKeys(nil)
if cfg.Certificates[0].CertificatePath != "/fake/path/asd" {
t.Fail()
t.Logf(`CertificatePath = %q, want "/fake/path/asd"`, cfg.Certificates[0].CertificatePath)
}
if cfg.Certificates[0].KeyPath != "/fake/path/asdf-key" {
t.Fail()
t.Logf(`KeyPath = %q, want "/fake/path/asdf-key"`, cfg.Certificates[0].KeyPath)
}
if cfg.Certificates[0].Action != "./fake action abcd efgh" {
t.Fail()
t.Logf(`Action = %q, want "./fake action abcd efgh"`, cfg.Certificates[0].Action)
}
}

View file

@ -1,45 +1,38 @@
package configuration package configuration
import ( import (
"log/slog"
"os"
"regexp" "regexp"
) )
func ValidateConfig(logger *slog.Logger, config ConfigFileData) { // IsValid tests if the config read from file has all required parameters set.
validationFailed := false //
// Exits the app if errors are detected
func (c *ConfigFileData) IsValid() ConfigValidationError {
err := ConfigValidationError{}
if config.BaseURL == "" { if c.BaseURL == "" {
logger.Error(`Field 'base_url' in config file is required!`) err.Add(`Field 'base_url' in config file is required!`)
validationFailed = true
} }
for _, cert := range config.Certificates { for _, cert := range c.Certificates {
if cert.Name == "" { if cert.Name == "" {
cert.Name = "unnamed_certificate" cert.Name = "unnamed_certificate"
logger.Error(`Field 'name' for certificates cannot be blank!`) err.Add(`Field 'name' for certificates cannot be blank!`)
validationFailed = true
} }
if cert.ApiKey == "" { if cert.CertificateSecret == "" {
logger.Error(`Field 'api_key' for certificate ` + cert.Name + " cannot be blank!") err.Add(`Field 'cert_secret' for certificate ` + cert.Name + " cannot be blank!")
validationFailed = true
} }
if cert.FilePath == "" { if cert.CertificatePath == "" {
logger.Error(`Field 'file_path' for certificate ` + cert.Name + " cannot be blank!") err.Add(`Field 'cert_path' for certificate ` + cert.Name + " cannot be blank!")
validationFailed = true
} }
re := regexp.MustCompile(`^[a-zA-Z0-9._-]+$`) re := regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
if !re.MatchString(cert.Name) { if !re.MatchString(cert.Name) {
logger.Error(`Field 'name' for certificate may only contain -_. and alphanumeric characters!`) err.Add(`Field 'name' for certificate may only contain -_. and alphanumeric characters!`)
validationFailed = true
} }
} }
if validationFailed { return err
logger.Error("Config file has errors! Please fix errors above! Exiting...", "config-path", ConfigFile)
os.Exit(1)
}
} }

View file

@ -1,6 +1,7 @@
package constants package constants
const Version = "0.1.1" const Version = "0.2.0"
const CertificateApiPath = "/certwarden/api/v1/download/certificates/" const CertificateApiPath = "/certwarden/api/v1/download/certificates/"
const KeyApiPath = "/certwarden/api/v1/download/privatekeys/"
const ApiKeyHeaderName = "X-API-Key" const ApiKeyHeaderName = "X-API-Key"
const UserAgent = "certwarden-deploy/" + Version + " +https://code.lila.network/adoralaura/certwarden-deploy" const UserAgent = "certwarden-deploy/" + Version + " +https://code.lila.network/adoralaura/certwarden-deploy"

View file

@ -1,27 +0,0 @@
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
}

View file

@ -10,11 +10,9 @@ import (
func InitializeLogger() *slog.Logger { func InitializeLogger() *slog.Logger {
logLevel := slog.LevelInfo logLevel := slog.LevelInfo
sourceLogging := false
if configuration.VerboseLogging { if configuration.VerboseLogging {
logLevel = slog.LevelDebug logLevel = slog.LevelDebug
sourceLogging = true
} }
if configuration.QuietLogging { if configuration.QuietLogging {
logLevel = slog.LevelError logLevel = slog.LevelError
@ -24,8 +22,7 @@ func InitializeLogger() *slog.Logger {
} }
opts := &slog.HandlerOptions{ opts := &slog.HandlerOptions{
Level: logLevel, Level: logLevel,
AddSource: sourceLogging,
} }
handler := slog.NewTextHandler(os.Stdout, opts) handler := slog.NewTextHandler(os.Stdout, opts)