diff --git a/README.md b/README.md index 9e10506..48a0ccb 100644 --- a/README.md +++ b/README.md @@ -18,25 +18,34 @@ 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` | + +### Health Check Configuration +SPIFFE Helper can expose and endpoint that can be used for health checking + +| Configuration | Description | Example Value | + |-------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------| + | `health_checks.enable_health_check` | Whether to start an HTTP server at the configured endpoint for the daemon health. Doesn't apply for non-daemon mode. | `false` | + | `health_checks.health_check_port` | The port to run the HTTP health server. | `8081` | +| `health_checks.health_check_path` | The URL path for the health check | `/healthz` | ### Configuration example ``` diff --git a/cmd/spiffe-helper/config/config.go b/cmd/spiffe-helper/config/config.go index 784e455..c1cc2b5 100644 --- a/cmd/spiffe-helper/config/config.go +++ b/cmd/spiffe-helper/config/config.go @@ -16,27 +16,31 @@ import ( ) const ( + daemonModeFlagName = "daemon-mode" defaultAgentAddress = "/tmp/spire-agent/public/api.sock" defaultCertFileMode = 0644 defaultKeyFileMode = 0600 defaultJWTBundleFileMode = 0600 defaultJWTSVIDFileMode = 0600 + defaultBindPort = 8081 + defaultHealthPath = "/healthz" ) type Config struct { - AddIntermediatesToBundle bool `hcl:"add_intermediates_to_bundle"` - AgentAddress string `hcl:"agent_address"` - Cmd string `hcl:"cmd"` - CmdArgs string `hcl:"cmd_args"` - PIDFileName string `hcl:"pid_file_name"` - CertDir string `hcl:"cert_dir"` - CertFileMode int `hcl:"cert_file_mode"` - KeyFileMode int `hcl:"key_file_mode"` - JWTBundleFileMode int `hcl:"jwt_bundle_file_mode"` - JWTSVIDFileMode int `hcl:"jwt_svid_file_mode"` - IncludeFederatedDomains bool `hcl:"include_federated_domains"` - RenewSignal string `hcl:"renew_signal"` - DaemonMode *bool `hcl:"daemon_mode"` + AddIntermediatesToBundle bool `hcl:"add_intermediates_to_bundle"` + AgentAddress string `hcl:"agent_address"` + Cmd string `hcl:"cmd"` + CmdArgs string `hcl:"cmd_args"` + PIDFileName string `hcl:"pid_file_name"` + CertDir string `hcl:"cert_dir"` + CertFileMode int `hcl:"cert_file_mode"` + KeyFileMode int `hcl:"key_file_mode"` + JWTBundleFileMode int `hcl:"jwt_bundle_file_mode"` + JWTSVIDFileMode int `hcl:"jwt_svid_file_mode"` + IncludeFederatedDomains bool `hcl:"include_federated_domains"` + RenewSignal string `hcl:"renew_signal"` + DaemonMode *bool `hcl:"daemon_mode"` + HealthCheck HealthCheckConfig `hcl:"health_checks"` // x509 configuration SVIDFileName string `hcl:"svid_file_name"` @@ -50,6 +54,12 @@ type Config struct { UnusedKeyPositions map[string][]token.Pos `hcl:",unusedKeyPositions"` } +type HealthCheckConfig struct { + ListenerEnabled bool `hcl:"listener_enabled"` + BindPort int `hcl:"bind_port"` + HealthPath string `hcl:"health_path"` +} + type JWTConfig struct { JWTAudience string `hcl:"jwt_audience"` JWTExtraAudiences []string `hcl:"jwt_extra_audiences"` @@ -76,7 +86,7 @@ func ParseConfig(file string) (*Config, error) { } // ParseConfigFlagOverrides handles command line arguments that override config file settings -func (c *Config) ParseConfigFlagOverrides(daemonModeFlag bool, daemonModeFlagName string) { +func (c *Config) ParseConfigFlagOverrides(daemonModeFlag bool) { if isFlagPassed(daemonModeFlagName) { // If daemon mode is set by CLI this takes precedence c.DaemonMode = &daemonModeFlag @@ -158,9 +168,28 @@ func (c *Config) ValidateConfig(log logrus.FieldLogger) error { c.JWTSVIDFileMode = defaultJWTSVIDFileMode } + if c.HealthCheck.ListenerEnabled && c.HealthCheck.BindPort < 0 { + return errors.New("bind port must be positive") + } else if c.HealthCheck.ListenerEnabled && c.HealthCheck.BindPort == 0 { + c.HealthCheck.BindPort = defaultBindPort + } + if c.HealthCheck.ListenerEnabled && c.HealthCheck.HealthPath == "" { + c.HealthCheck.HealthPath = defaultHealthPath + } + return nil } +func ParseConfigFile(log logrus.FieldLogger, configFile string, daemonModeFlag bool) (*Config, error) { + log.Infof("Using configuration file: %q", configFile) + hclConfig, err := ParseConfig(configFile) + if err != nil { + return nil, fmt.Errorf("failed to parse %q: %w", configFile, err) + } + hclConfig.ParseConfigFlagOverrides(daemonModeFlag) + return hclConfig, nil +} + // checkForUnknownConfig looks for any unknown configuration keys and returns an error if one is found func (c *Config) checkForUnknownConfig() error { if len(c.UnusedKeyPositions) != 0 { diff --git a/cmd/spiffe-helper/config/config_test.go b/cmd/spiffe-helper/config/config_test.go index 94e71ad..aa44220 100644 --- a/cmd/spiffe-helper/config/config_test.go +++ b/cmd/spiffe-helper/config/config_test.go @@ -10,10 +10,6 @@ import ( "github.com/stretchr/testify/require" ) -const ( - daemonModeFlagName = "daemon-mode" -) - func TestParseConfig(t *testing.T) { c, err := ParseConfig("testdata/helper.conf") @@ -314,7 +310,7 @@ func TestDaemonModeFlag(t *testing.T) { err := flag.Set(daemonModeFlagName, "false") require.NoError(t, err) - config.ParseConfigFlagOverrides(*daemonModeFlag, daemonModeFlagName) + config.ParseConfigFlagOverrides(*daemonModeFlag) require.NotNil(t, config.DaemonMode) assert.Equal(t, false, *config.DaemonMode) } diff --git a/cmd/spiffe-helper/main.go b/cmd/spiffe-helper/main.go index 295b67b..3caceb9 100644 --- a/cmd/spiffe-helper/main.go +++ b/cmd/spiffe-helper/main.go @@ -3,13 +3,13 @@ package main import ( "context" "flag" - "fmt" "os" "os/signal" "syscall" "github.com/sirupsen/logrus" "github.com/spiffe/spiffe-helper/cmd/spiffe-helper/config" + "github.com/spiffe/spiffe-helper/pkg/health" "github.com/spiffe/spiffe-helper/pkg/sidecar" ) @@ -23,38 +23,44 @@ func main() { flag.Parse() log := logrus.WithField("system", "spiffe-helper") - if err := startSidecar(*configFile, *daemonModeFlag, log); err != nil { + hclConfig, err := config.ParseConfigFile(log, *configFile, *daemonModeFlag) + if err != nil { + log.WithError(err).Errorf("failed to parse configuration") + os.Exit(1) + } + + if err := hclConfig.ValidateConfig(log); err != nil { + log.WithError(err).Errorf("invalid configuration") + os.Exit(1) + } + + spiffeSidecar, err := startSidecar(hclConfig, log) + if err != nil { log.WithError(err).Errorf("Error starting spiffe-helper") os.Exit(1) } + if err := health.StartHealthServer(hclConfig, log, spiffeSidecar); err != nil { + log.WithError(err).Errorf("Error starting spiffe-helper health check server") + os.Exit(1) + } + log.Infof("Exiting") os.Exit(0) } -func startSidecar(configFile string, daemonModeFlag bool, log logrus.FieldLogger) error { +func startSidecar(hclConfig *config.Config, log logrus.FieldLogger) (*sidecar.Sidecar, error) { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - log.Infof("Using configuration file: %q", configFile) - 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) - } - sidecarConfig := config.NewSidecarConfig(hclConfig, log) spiffeSidecar := sidecar.New(sidecarConfig) if !*hclConfig.DaemonMode { log.Info("Daemon mode disabled") - return spiffeSidecar.Run(ctx) + return spiffeSidecar, spiffeSidecar.Run(ctx) } log.Info("Launching daemon") - return spiffeSidecar.RunDaemon(ctx) + return spiffeSidecar, spiffeSidecar.RunDaemon(ctx) } diff --git a/pkg/health/health.go b/pkg/health/health.go new file mode 100644 index 0000000..b8fd93b --- /dev/null +++ b/pkg/health/health.go @@ -0,0 +1,41 @@ +package health + +import ( + "encoding/json" + "net/http" + "strconv" + "time" + + "github.com/sirupsen/logrus" + "github.com/spiffe/spiffe-helper/cmd/spiffe-helper/config" + "github.com/spiffe/spiffe-helper/pkg/sidecar" +) + +func StartHealthServer(hclConfig *config.Config, log logrus.FieldLogger, sidecar *sidecar.Sidecar) error { + if *hclConfig.DaemonMode && hclConfig.HealthCheck.ListenerEnabled { + http.HandleFunc(hclConfig.HealthCheck.HealthPath, func(w http.ResponseWriter, _ *http.Request) { + healthy := sidecar.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(sidecar.GetFileWritesSuccess()) + if err != nil { + statusText = string(b) + } + http.Error(w, statusText, http.StatusServiceUnavailable) + } + }) + server := &http.Server{ + Addr: ":" + strconv.Itoa(hclConfig.HealthCheck.BindPort), + 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 891b79f..1f8fa5e 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 c462402..7bee280 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)