From d378b30156e8b69fd932fde9e076a62a90ffc805 Mon Sep 17 00:00:00 2001 From: Keegan Witt Date: Sat, 7 Dec 2024 16:39:42 -0500 Subject: [PATCH] Add HTTP health server (closes #115) Signed-off-by: Keegan Witt --- README.md | 40 ++++++++++++----------- cmd/spiffe-helper/config/config.go | 7 ++++ cmd/spiffe-helper/main.go | 51 +++++++++++++++++++++++++++++- pkg/sidecar/sidecar.go | 46 +++++++++++++++++++++++---- pkg/sidecar/sidecar_test.go | 5 +-- 5 files changed, 120 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 9e10506a..2fca129f 100644 --- a/README.md +++ b/README.md @@ -18,25 +18,27 @@ The flag `-exitWhenReady` is also supported. ## Configuration The configuration file is an [HCL](https://github.com/hashicorp/hcl) formatted file that defines the following configurations: - | Configuration | Description | Example Value | - |-------------------------------|----------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------| - | `agent_address` | Socket address of SPIRE Agent. | `"/tmp/agent.sock"` | - | `cmd` | The path to the process to launch. | `"ghostunnel"` | - | `cmd_args` | The arguments of the process to launch. | `"server --listen localhost:8002 --target localhost:8001--keystore certs/svid_key.pem --cacert certs/svid_bundle.pem --allow-uri-san spiffe://example.org/Database"` | - | `cert_dir` | Directory name to store the fetched certificates. This directory must be created previously. | `"certs"` | - | `daemon_mode` | Toggle running as a daemon, keeping X.509 and JWT up to date; or just fetch X.509 and JWT and exit 0 | `true` | - | `add_intermediates_to_bundle` | Add intermediate certificates into Bundle file instead of SVID file. | `true` | - | `renew_signal` | The signal that the process to be launched expects to reload the certificates. It is not supported on Windows. | `"SIGUSR1"` | - | `svid_file_name` | File name to be used to store the X.509 SVID public certificate in PEM format. | `"svid.pem"` | - | `svid_key_file_name` | File name to be used to store the X.509 SVID private key and public certificate in PEM format. | `"svid_key.pem"` | - | `svid_bundle_file_name` | File name to be used to store the X.509 SVID Bundle in PEM format. | `"svid_bundle.pem"` | - | `jwt_svids` | An array with the audience, optional extra audiences array, and file name to store the JWT SVIDs. File is Base64-encoded string). | `[{jwt_audience="your-audience", jwt_extra_audiences=["your-extra-audience-1", "your-extra-audience-2"], jwt_svid_file_name="jwt_svid.token"}]` | - | `jwt_bundle_file_name` | File name to be used to store JWT Bundle in JSON format. | `"jwt_bundle.json"` | - | `include_federated_domains` | Include trust domains from federated servers in the CA bundle. | `true` | - | `cert_file_mode` | The octal file mode to use when saving the X.509 public certificate file. | `0644` | - | `key_file_mode` | The octal file mode to use when saving the X.509 private key file. | `0600` | - | `jwt_bundle_file_mode` | The octal file mode to use when saving a JWT Bundle file. | `0600` | - | `jwt_svid_file_mode` | The octal file mode to use when saving a JWT SVID file. | `0600` | + | Configuration | Description | Example Value | + |-------------------------------|-----------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------| + | `agent_address` | Socket address of SPIRE Agent. | `"/tmp/agent.sock"` | + | `cmd` | The path to the process to launch. | `"ghostunnel"` | + | `cmd_args` | The arguments of the process to launch. | `"server --listen localhost:8002 --target localhost:8001--keystore certs/svid_key.pem --cacert certs/svid_bundle.pem --allow-uri-san spiffe://example.org/Database"` | + | `cert_dir` | Directory name to store the fetched certificates. This directory must be created previously. | `"certs"` | + | `daemon_mode` | Toggle running as a daemon, keeping X.509 and JWT up to date; or just fetch X.509 and JWT and exit 0 | `true` | + | `add_intermediates_to_bundle` | Add intermediate certificates into Bundle file instead of SVID file. | `true` | + | `renew_signal` | The signal that the process to be launched expects to reload the certificates. It is not supported on Windows. | `"SIGUSR1"` | + | `svid_file_name` | File name to be used to store the X.509 SVID public certificate in PEM format. | `"svid.pem"` | + | `svid_key_file_name` | File name to be used to store the X.509 SVID private key and public certificate in PEM format. | `"svid_key.pem"` | + | `svid_bundle_file_name` | File name to be used to store the X.509 SVID Bundle in PEM format. | `"svid_bundle.pem"` | + | `jwt_svids` | An array with the audience, optional extra audiences array, and file name to store the JWT SVIDs. File is Base64-encoded string). | `[{jwt_audience="your-audience", jwt_extra_audiences=["your-extra-audience-1", "your-extra-audience-2"], jwt_svid_file_name="jwt_svid.token"}]` | + | `jwt_bundle_file_name` | File name to be used to store JWT Bundle in JSON format. | `"jwt_bundle.json"` | + | `include_federated_domains` | Include trust domains from federated servers in the CA bundle. | `true` | + | `cert_file_mode` | The octal file mode to use when saving the X.509 public certificate file. | `0644` | + | `key_file_mode` | The octal file mode to use when saving the X.509 private key file. | `0600` | + | `jwt_bundle_file_mode` | The octal file mode to use when saving a JWT Bundle file. | `0600` | + | `jwt_svid_file_mode` | The octal file mode to use when saving a JWT SVID file. | `0600` | + | enable_health_check | Whether to start an HTTP server at `/healthz` with the daemon health. Doesn't apply for non-daemon mode. | `false` | + | health_check_port | The port to run the HTTP health server. | `8081` | ### Configuration example ``` diff --git a/cmd/spiffe-helper/config/config.go b/cmd/spiffe-helper/config/config.go index 784e455e..47b949b4 100644 --- a/cmd/spiffe-helper/config/config.go +++ b/cmd/spiffe-helper/config/config.go @@ -37,6 +37,8 @@ type Config struct { IncludeFederatedDomains bool `hcl:"include_federated_domains"` RenewSignal string `hcl:"renew_signal"` DaemonMode *bool `hcl:"daemon_mode"` + EnableHealthCheck *bool `hcl:"enable_health_check"` + HealthCheckPort int `hcl:"health_check_port"` // x509 configuration SVIDFileName string `hcl:"svid_file_name"` @@ -158,6 +160,11 @@ func (c *Config) ValidateConfig(log logrus.FieldLogger) error { c.JWTSVIDFileMode = defaultJWTSVIDFileMode } + if c.EnableHealthCheck == nil { + defaultEnableHealthCheck := false + c.EnableHealthCheck = &defaultEnableHealthCheck + } + return nil } diff --git a/cmd/spiffe-helper/main.go b/cmd/spiffe-helper/main.go index 295b67b8..0291b171 100644 --- a/cmd/spiffe-helper/main.go +++ b/cmd/spiffe-helper/main.go @@ -2,11 +2,15 @@ package main import ( "context" + "encoding/json" "flag" "fmt" + "net/http" "os" "os/signal" + "strconv" "syscall" + "time" "github.com/sirupsen/logrus" "github.com/spiffe/spiffe-helper/cmd/spiffe-helper/config" @@ -28,10 +32,17 @@ func main() { os.Exit(1) } + if err := startHealthServer(*configFile, *daemonModeFlag, log); err != nil { + log.WithError(err).Errorf("Error starting spiffe-helper health check server") + os.Exit(1) + } + log.Infof("Exiting") os.Exit(0) } +var spiffeSidecar *sidecar.Sidecar + func startSidecar(configFile string, daemonModeFlag bool, log logrus.FieldLogger) error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() @@ -48,7 +59,7 @@ func startSidecar(configFile string, daemonModeFlag bool, log logrus.FieldLogger } sidecarConfig := config.NewSidecarConfig(hclConfig, log) - spiffeSidecar := sidecar.New(sidecarConfig) + spiffeSidecar = sidecar.New(sidecarConfig) if !*hclConfig.DaemonMode { log.Info("Daemon mode disabled") @@ -58,3 +69,41 @@ func startSidecar(configFile string, daemonModeFlag bool, log logrus.FieldLogger log.Info("Launching daemon") return spiffeSidecar.RunDaemon(ctx) } + +func startHealthServer(configFile string, daemonModeFlag bool, log logrus.FieldLogger) error { + hclConfig, err := config.ParseConfig(configFile) + if err != nil { + return fmt.Errorf("failed to parse %q: %w", configFile, err) + } + hclConfig.ParseConfigFlagOverrides(daemonModeFlag, daemonModeFlagName) + if err := hclConfig.ValidateConfig(log); err != nil { + return fmt.Errorf("invalid configuration: %w", err) + } + + if *hclConfig.DaemonMode && *hclConfig.EnableHealthCheck { + http.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { + healthy := spiffeSidecar.CheckHealth() + if healthy { + _, err := w.Write([]byte(http.StatusText(http.StatusOK))) + log.Error(err) + if err != nil { + return + } + } else { + statusText := http.StatusText(http.StatusServiceUnavailable) + b, err := json.Marshal(spiffeSidecar.GetFileWritesSuccess()) + if err != nil { + statusText = string(b) + } + http.Error(w, statusText, http.StatusServiceUnavailable) + } + }) + server := &http.Server{ + Addr: ":" + strconv.Itoa(hclConfig.HealthCheckPort), + ReadHeaderTimeout: 5 * time.Second, + WriteTimeout: 5 * time.Second, + } + log.Fatal(server.ListenAndServe()) + } + return nil +} diff --git a/pkg/sidecar/sidecar.go b/pkg/sidecar/sidecar.go index 891b79fb..1f8fa5e0 100644 --- a/pkg/sidecar/sidecar.go +++ b/pkg/sidecar/sidecar.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "os/exec" + "path" "strconv" "strings" "sync" @@ -24,20 +25,23 @@ import ( // Sidecar is the component that consumes the Workload API and renews certs // implements the interface Sidecar type Sidecar struct { - config *Config - client *workloadapi.Client - jwtSource *workloadapi.JWTSource - processRunning int32 - process *os.Process - certReadyChan chan struct{} + config *Config + client *workloadapi.Client + jwtSource *workloadapi.JWTSource + processRunning int32 + process *os.Process + certReadyChan chan struct{} + fileWritesSuccess map[string]bool } // New creates a new SPIFFE sidecar func New(config *Config) *Sidecar { - return &Sidecar{ + sidecar := &Sidecar{ config: config, certReadyChan: make(chan struct{}, 1), } + sidecar.fileWritesSuccess = make(map[string]bool) + return sidecar } // RunDaemon starts the main loop @@ -167,10 +171,19 @@ func (s *Sidecar) setupClients(ctx context.Context) error { // updateCertificates Updates the certificates stored in disk and signal the Process to restart func (s *Sidecar) updateCertificates(svidResponse *workloadapi.X509Context) { s.config.Log.Debug("Updating X.509 certificates") + svidFile := path.Join(s.config.CertDir, s.config.SVIDFileName) + svidKeyFile := path.Join(s.config.CertDir, s.config.SVIDKeyFileName) + svidBundleFile := path.Join(s.config.CertDir, s.config.SVIDBundleFileName) if err := disk.WriteX509Context(svidResponse, s.config.AddIntermediatesToBundle, s.config.IncludeFederatedDomains, s.config.CertDir, s.config.SVIDFileName, s.config.SVIDKeyFileName, s.config.SVIDBundleFileName, s.config.CertFileMode, s.config.KeyFileMode); err != nil { s.config.Log.WithError(err).Error("Unable to dump bundle") + s.fileWritesSuccess[svidFile] = false + s.fileWritesSuccess[svidKeyFile] = false + s.fileWritesSuccess[svidBundleFile] = false return } + s.fileWritesSuccess[svidFile] = true + s.fileWritesSuccess[svidKeyFile] = true + s.fileWritesSuccess[svidBundleFile] = true s.config.Log.Info("X.509 certificates updated") if s.config.Cmd != "" { @@ -300,10 +313,13 @@ func (s *Sidecar) performJWTSVIDUpdate(ctx context.Context, jwtAudience string, return nil, err } + jwtSVIDPath := path.Join(s.config.CertDir, jwtSVIDFilename) if err = disk.WriteJWTSVID(jwtSVID, s.config.CertDir, jwtSVIDFilename, s.config.JWTSVIDFileMode); err != nil { s.config.Log.Errorf("Unable to update JWT SVID: %v", err) + s.fileWritesSuccess[jwtSVIDPath] = false return nil, err } + s.fileWritesSuccess[jwtSVIDPath] = true s.config.Log.Info("JWT SVID updated") return jwtSVID, nil @@ -397,10 +413,13 @@ type JWTBundlesWatcher struct { // OnJWTBundlesUpdate is run every time a bundle is updated func (w JWTBundlesWatcher) OnJWTBundlesUpdate(jwkSet *jwtbundle.Set) { w.sidecar.config.Log.Debug("Updating JWT bundle") + jwtBundleFilePath := path.Join(w.sidecar.config.CertDir, w.sidecar.config.JWTBundleFilename) if err := disk.WriteJWTBundleSet(jwkSet, w.sidecar.config.CertDir, w.sidecar.config.JWTBundleFilename, w.sidecar.config.JWTBundleFileMode); err != nil { w.sidecar.config.Log.Errorf("Error writing JWT Bundle to disk: %v", err) + w.sidecar.fileWritesSuccess[jwtBundleFilePath] = false return } + w.sidecar.fileWritesSuccess[jwtBundleFilePath] = true w.sidecar.config.Log.Info("JWT bundle updated") } @@ -411,3 +430,16 @@ func (w JWTBundlesWatcher) OnJWTBundlesWatchError(err error) { w.sidecar.config.Log.Errorf("Error while watching JWT bundles: %v", err) } } + +func (s *Sidecar) CheckHealth() bool { + for _, success := range s.fileWritesSuccess { + if !success { + return false + } + } + return true +} + +func (s *Sidecar) GetFileWritesSuccess() map[string]bool { + return s.fileWritesSuccess +} diff --git a/pkg/sidecar/sidecar_test.go b/pkg/sidecar/sidecar_test.go index c462402b..7bee280f 100644 --- a/pkg/sidecar/sidecar_test.go +++ b/pkg/sidecar/sidecar_test.go @@ -91,8 +91,9 @@ func TestSidecar_RunDaemon(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) sidecar := Sidecar{ - config: config, - certReadyChan: make(chan struct{}, 1), + config: config, + certReadyChan: make(chan struct{}, 1), + fileWritesSuccess: make(map[string]bool), } defer close(sidecar.certReadyChan)