From 37b4df9ab7caade6dcfeeb182e62172819b7703f Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Mon, 10 Dec 2018 10:59:28 -0800 Subject: [PATCH 01/12] Initialize rand seed. step ca renew jitter was always producing the same output. --- cmd/step/main.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cmd/step/main.go b/cmd/step/main.go index 0183b12e..95382385 100644 --- a/cmd/step/main.go +++ b/cmd/step/main.go @@ -3,10 +3,12 @@ package main import ( "fmt" "log" + "math/rand" "net/http" "os" "reflect" "regexp" + "time" "github.com/urfave/cli" @@ -36,6 +38,7 @@ var BuildTime = "N/A" func init() { config.Set(Version, BuildTime) + rand.Seed(time.Now().UnixNano()) } func main() { From be93be7ae44d63056473e5f70f689c5e05143c48 Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Mon, 10 Dec 2018 11:02:07 -0800 Subject: [PATCH 02/12] Move step ca renew to a new file. --- command/ca/certificate.go | 179 --------------------------------- command/ca/renew.go | 201 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 201 insertions(+), 179 deletions(-) create mode 100644 command/ca/renew.go diff --git a/command/ca/certificate.go b/command/ca/certificate.go index 10ae2b24..5f708f42 100644 --- a/command/ca/certificate.go +++ b/command/ca/certificate.go @@ -1,15 +1,10 @@ package ca import ( - "crypto/tls" "crypto/x509" "encoding/pem" - "fmt" - "math/rand" - "net/http" "os" "strings" - "time" "github.com/pkg/errors" "github.com/smallstep/certificates/api" @@ -112,79 +107,6 @@ $ step ca sign --token $TOKEN --not-after=1h internal.csr internal.crt } } -func renewCertificateCommand() cli.Command { - return cli.Command{ - Name: "renew", - Action: command.ActionFunc(renewCertificateAction), - Usage: "renew a valid certificate", - UsageText: `**step ca renew** - [**--ca-url**=] [**--root**=] - [**--out**=] [**--expires-in**=] [**--force**]`, - Description: ` -**step ca renew** command renews the given certificates on the certificate -authority and writes the new certificate to disk either overwriting -or using a new file if the **--out**= flag is used. - -## POSITIONAL ARGUMENTS - - -: The certificate in PEM format that we want to renew. - - -: They key file of the certificate. - -## EXAMPLES - -Renew a certificate with the configured CA: -''' -$ step ca renew internal.crt internal.key -Would you like to overwrite internal.crt [Y/n]: y -''' - -Renew a certificate without overwriting the previous certificate: -''' -$ step ca renew --out renewed.crt internal.crt internal.key -''' - -Renew a certificate forcing the overwrite of the previous certificate: -''' -$ step ca renew --force internal.crt internal.key -''' - -Renew a certificate providing the <--ca-url> and <--root> flags: -''' -$ step ca renew --ca-url https://ca.smallstep.com:9000 \ - --root /path/to/root_ca.crt internal.crt internal.key -Would you like to overwrite internal.crt [Y/n]: y -''' - -Renew skipped because it was too early: -''' -$ step ca renew --expires-in 8h internal.crt internal.key -certificate not renewed: expires in 10h52m5s -'''`, - Flags: []cli.Flag{ - caURLFlag, - rootFlag, - cli.StringFlag{ - Name: "out,output-file", - Usage: "The new certificate path. Defaults to overwriting the positional argument", - }, - cli.StringFlag{ - Name: "expires-in", - Usage: `The check that will be performed before renewing the certificate. The -certificate renew will be skipped if the time to expiration is greater than the -passed one. A random jitter (duration/20) will be added to avoid multiple -services hitting the renew endpoint at the same time. The is a -sequence of decimal numbers, each with optional fraction and a unit suffix, such -as "300ms", "-1.5h" or "2h45m". Valid time units are "ns", "us" (or "µs"), "ms", -"s", "m", "h".`, - }, - flags.Force, - }, - } -} - func newCertificateAction(ctx *cli.Context) error { if err := errs.NumberOfArguments(ctx, 3); err != nil { return err @@ -378,104 +300,3 @@ func signCertificateRequest(ctx *cli.Context, token string, csr api.CertificateR data := append(pem.EncodeToMemory(serverBlock), pem.EncodeToMemory(caBlock)...) return utils.WriteFile(crtFile, data, 0600) } - -func renewCertificateAction(ctx *cli.Context) error { - if err := errs.NumberOfArguments(ctx, 2); err != nil { - return err - } - - args := ctx.Args() - crtFile := args.Get(0) - keyFile := args.Get(1) - - outFile := ctx.String("out") - if len(outFile) == 0 { - outFile = crtFile - } - - root := ctx.String("root") - if len(root) == 0 { - root = pki.GetRootCAPath() - } - - caURL := ctx.String("ca-url") - if len(caURL) == 0 { - return errs.RequiredFlag(ctx, "ca-url") - } - - cert, err := tls.LoadX509KeyPair(crtFile, keyFile) - if err != nil { - return errors.Wrap(err, "error loading certificates") - } - if len(cert.Certificate) == 0 { - return errors.New("error loading certificate: certificate chain is empty") - } - - // Do not renew if (now - cert.notBefore) > (expiresIn + jitter) - if s := ctx.String("expires-in"); len(s) > 0 { - duration, err := time.ParseDuration(s) - if err != nil { - return errs.InvalidFlagValue(ctx, "expires-in", s, "") - } - - leaf, err := x509.ParseCertificate(cert.Certificate[0]) - if err != nil { - return errors.Wrap(err, "error parsing certificate") - } - - now := time.Now() - jitter := rand.Int63n(int64(duration / 20)) - if d := leaf.NotAfter.Sub(now); d > duration+time.Duration(jitter) { - fmt.Printf("certificate not renewed: expires in %s\n", d.Round(time.Second)) - return nil - } - } - - rootInt, err := pemutil.Read(root) - if err != nil { - return err - } - - rootCert, ok := rootInt.(*x509.Certificate) - if !ok { - return errors.Errorf("error parsing %s: file is not a root certificate", root) - } - - pool := x509.NewCertPool() - pool.AddCert(rootCert) - - tr := &http.Transport{ - TLSClientConfig: &tls.Config{ - Certificates: []tls.Certificate{cert}, - RootCAs: pool, - PreferServerCipherSuites: true, - }, - } - - client, err := ca.NewClient(caURL, ca.WithTransport(tr)) - if err != nil { - return err - } - - resp, err := client.Renew(tr) - if err != nil { - return errors.Wrap(err, "error renewing certificate") - } - - serverBlock, err := pemutil.Serialize(resp.ServerPEM.Certificate) - if err != nil { - return err - } - caBlock, err := pemutil.Serialize(resp.CaPEM.Certificate) - if err != nil { - return err - } - data := append(pem.EncodeToMemory(serverBlock), pem.EncodeToMemory(caBlock)...) - - if err := utils.WriteFile(outFile, data, 0600); err != nil { - return errs.FileError(err, outFile) - } - - ui.Printf("Your certificate has been saved in %s.\n", outFile) - return nil -} diff --git a/command/ca/renew.go b/command/ca/renew.go new file mode 100644 index 00000000..be78ad13 --- /dev/null +++ b/command/ca/renew.go @@ -0,0 +1,201 @@ +package ca + +import ( + "crypto/tls" + "crypto/x509" + "encoding/pem" + "fmt" + "math/rand" + "net/http" + "time" + + "github.com/pkg/errors" + "github.com/smallstep/certificates/ca" + "github.com/smallstep/cli/command" + "github.com/smallstep/cli/crypto/pemutil" + "github.com/smallstep/cli/crypto/pki" + "github.com/smallstep/cli/errs" + "github.com/smallstep/cli/flags" + "github.com/smallstep/cli/ui" + "github.com/smallstep/cli/utils" + "github.com/urfave/cli" +) + +func renewCertificateCommand() cli.Command { + return cli.Command{ + Name: "renew", + Action: command.ActionFunc(renewCertificateAction), + Usage: "renew a valid certificate", + UsageText: `**step ca renew** + [**--ca-url**=] [**--root**=] + [**--out**=] [**--expires-in**=] [**--force**]`, + Description: ` +**step ca renew** command renews the given certificates on the certificate +authority and writes the new certificate to disk either overwriting +or using a new file if the **--out**= flag is used. + +## POSITIONAL ARGUMENTS + + +: The certificate in PEM format that we want to renew. + + +: They key file of the certificate. + +## EXAMPLES + +Renew a certificate with the configured CA: +''' +$ step ca renew internal.crt internal.key +Would you like to overwrite internal.crt [Y/n]: y +''' + +Renew a certificate without overwriting the previous certificate: +''' +$ step ca renew --out renewed.crt internal.crt internal.key +''' + +Renew a certificate forcing the overwrite of the previous certificate: +''' +$ step ca renew --force internal.crt internal.key +''' + +Renew a certificate providing the <--ca-url> and <--root> flags: +''' +$ step ca renew --ca-url https://ca.smallstep.com:9000 \ + --root /path/to/root_ca.crt internal.crt internal.key +Would you like to overwrite internal.crt [Y/n]: y +''' + +Renew skipped because it was too early: +''' +$ step ca renew --expires-in 8h internal.crt internal.key +certificate not renewed: expires in 10h52m5s +'''`, + Flags: []cli.Flag{ + caURLFlag, + rootFlag, + cli.StringFlag{ + Name: "out,output-file", + Usage: "The new certificate path. Defaults to overwriting the positional argument", + }, + cli.StringFlag{ + Name: "expires-in", + Usage: `The check that will be performed before renewing the certificate. The +certificate renew will be skipped if the time to expiration is greater than the +passed one. A random jitter (duration/20) will be added to avoid multiple +services hitting the renew endpoint at the same time. The is a +sequence of decimal numbers, each with optional fraction and a unit suffix, such +as "300ms", "-1.5h" or "2h45m". Valid time units are "ns", "us" (or "µs"), "ms", +"s", "m", "h".`, + }, + flags.Force, + }, + } +} + +func renewCertificateAction(ctx *cli.Context) error { + jitter := rand.Int63n(int64(60)) + fmt.Println(jitter) + if true { + return fmt.Errorf("fooo") + } + if err := errs.NumberOfArguments(ctx, 2); err != nil { + return err + } + + args := ctx.Args() + crtFile := args.Get(0) + keyFile := args.Get(1) + + outFile := ctx.String("out") + if len(outFile) == 0 { + outFile = crtFile + } + + root := ctx.String("root") + if len(root) == 0 { + root = pki.GetRootCAPath() + } + + caURL := ctx.String("ca-url") + if len(caURL) == 0 { + return errs.RequiredFlag(ctx, "ca-url") + } + + cert, err := tls.LoadX509KeyPair(crtFile, keyFile) + if err != nil { + return errors.Wrap(err, "error loading certificates") + } + if len(cert.Certificate) == 0 { + return errors.New("error loading certificate: certificate chain is empty") + } + + // Do not renew if (now - cert.notBefore) > (expiresIn + jitter) + if s := ctx.String("expires-in"); len(s) > 0 { + duration, err := time.ParseDuration(s) + if err != nil { + return errs.InvalidFlagValue(ctx, "expires-in", s, "") + } + + leaf, err := x509.ParseCertificate(cert.Certificate[0]) + if err != nil { + return errors.Wrap(err, "error parsing certificate") + } + + now := time.Now() + jitter := rand.Int63n(int64(duration / 20)) + if d := leaf.NotAfter.Sub(now); d > duration+time.Duration(jitter) { + fmt.Printf("certificate not renewed: expires in %s\n", d.Round(time.Second)) + return nil + } + } + + rootInt, err := pemutil.Read(root) + if err != nil { + return err + } + + rootCert, ok := rootInt.(*x509.Certificate) + if !ok { + return errors.Errorf("error parsing %s: file is not a root certificate", root) + } + + pool := x509.NewCertPool() + pool.AddCert(rootCert) + + tr := &http.Transport{ + TLSClientConfig: &tls.Config{ + Certificates: []tls.Certificate{cert}, + RootCAs: pool, + PreferServerCipherSuites: true, + }, + } + + client, err := ca.NewClient(caURL, ca.WithTransport(tr)) + if err != nil { + return err + } + + resp, err := client.Renew(tr) + if err != nil { + return errors.Wrap(err, "error renewing certificate") + } + + serverBlock, err := pemutil.Serialize(resp.ServerPEM.Certificate) + if err != nil { + return err + } + caBlock, err := pemutil.Serialize(resp.CaPEM.Certificate) + if err != nil { + return err + } + data := append(pem.EncodeToMemory(serverBlock), pem.EncodeToMemory(caBlock)...) + + if err := utils.WriteFile(outFile, data, 0600); err != nil { + return errs.FileError(err, outFile) + } + + ui.Printf("Your certificate has been saved in %s.\n", outFile) + return nil +} From d1a5dc494693ded36f804d83a82f1e3a214b90cb Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Mon, 10 Dec 2018 11:16:35 -0800 Subject: [PATCH 03/12] Remove debug statement. --- command/ca/renew.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/command/ca/renew.go b/command/ca/renew.go index be78ad13..2fea1525 100644 --- a/command/ca/renew.go +++ b/command/ca/renew.go @@ -95,11 +95,6 @@ as "300ms", "-1.5h" or "2h45m". Valid time units are "ns", "us" (or "µs"), "ms" } func renewCertificateAction(ctx *cli.Context) error { - jitter := rand.Int63n(int64(60)) - fmt.Println(jitter) - if true { - return fmt.Errorf("fooo") - } if err := errs.NumberOfArguments(ctx, 2); err != nil { return err } From 53677e0481204b79929462526ebd7687b8e79ec4 Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Mon, 10 Dec 2018 18:27:16 -0800 Subject: [PATCH 04/12] Add initial version of renewal daemon. Fixes #44 --- command/ca/renew.go | 232 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 197 insertions(+), 35 deletions(-) diff --git a/command/ca/renew.go b/command/ca/renew.go index 2fea1525..233342a4 100644 --- a/command/ca/renew.go +++ b/command/ca/renew.go @@ -4,12 +4,17 @@ import ( "crypto/tls" "crypto/x509" "encoding/pem" - "fmt" + "log" "math/rand" "net/http" + "os" + "os/signal" + "strconv" + "syscall" "time" "github.com/pkg/errors" + "github.com/smallstep/certificates/api" "github.com/smallstep/certificates/ca" "github.com/smallstep/cli/command" "github.com/smallstep/cli/crypto/pemutil" @@ -90,12 +95,33 @@ as "300ms", "-1.5h" or "2h45m". Valid time units are "ns", "us" (or "µs"), "ms" "s", "m", "h".`, }, flags.Force, + cli.BoolFlag{ + Name: "daemon", + Usage: `Run the renew command as a daemon, renewing the certificate when required, and +overwriting it if it's necessary. By default it will automatically renew the +certificate after 2/3 of the time to expire has passed. This behavior can be +changed using the flag **--expires-in**, in this case, it will only renew the +certificate if the time to expiration is greater than the passed one.`, + }, + cli.IntFlag{ + Name: "pid", + Usage: `The process id to signal after the certificate has been renewed. By default it +will use the SIGHUP (1) signal, but it can be configured with the **--signal** +flag.`, + }, + cli.IntFlag{ + Name: "signal", + Usage: `The signal to send to the selected PID, so it can reload the +configuration and load the new certificate.`, + Value: int(syscall.SIGHUP), + }, }, } } func renewCertificateAction(ctx *cli.Context) error { - if err := errs.NumberOfArguments(ctx, 2); err != nil { + err := errs.NumberOfArguments(ctx, 2) + if err != nil { return err } @@ -108,9 +134,9 @@ func renewCertificateAction(ctx *cli.Context) error { outFile = crtFile } - root := ctx.String("root") - if len(root) == 0 { - root = pki.GetRootCAPath() + rootFile := ctx.String("root") + if len(rootFile) == 0 { + rootFile = pki.GetRootCAPath() } caURL := ctx.String("ca-url") @@ -118,6 +144,23 @@ func renewCertificateAction(ctx *cli.Context) error { return errs.RequiredFlag(ctx, "ca-url") } + var expiresIn time.Duration + if s := ctx.String("expires-in"); len(s) > 0 { + if expiresIn, err = time.ParseDuration(s); err != nil { + return errs.InvalidFlagValue(ctx, "expires-in", s, "") + } + } + + pid := ctx.Int("pid") + if ctx.IsSet("pid") && pid <= 0 { + return errs.InvalidFlagValue(ctx, "pid", strconv.Itoa(pid), "") + } + + signum := ctx.Int("signal") + if ctx.IsSet("signal") && signum <= 0 { + return errs.InvalidFlagValue(ctx, "signal", strconv.Itoa(signum), "") + } + cert, err := tls.LoadX509KeyPair(crtFile, keyFile) if err != nil { return errors.Wrap(err, "error loading certificates") @@ -126,36 +169,91 @@ func renewCertificateAction(ctx *cli.Context) error { return errors.New("error loading certificate: certificate chain is empty") } - // Do not renew if (now - cert.notBefore) > (expiresIn + jitter) - if s := ctx.String("expires-in"); len(s) > 0 { - duration, err := time.ParseDuration(s) - if err != nil { - return errs.InvalidFlagValue(ctx, "expires-in", s, "") - } - - leaf, err := x509.ParseCertificate(cert.Certificate[0]) - if err != nil { - return errors.Wrap(err, "error parsing certificate") - } - - now := time.Now() - jitter := rand.Int63n(int64(duration / 20)) - if d := leaf.NotAfter.Sub(now); d > duration+time.Duration(jitter) { - fmt.Printf("certificate not renewed: expires in %s\n", d.Round(time.Second)) - return nil - } + leaf, err := x509.ParseCertificate(cert.Certificate[0]) + if err != nil { + return errors.Wrap(err, "error parsing certificate") + } + if leaf.NotAfter.Before(time.Now()) { + return errors.New("cannot renew an expired certificate") } - rootInt, err := pemutil.Read(root) + renewer, err := newRenewer(caURL, crtFile, keyFile, rootFile) if err != nil { return err } - rootCert, ok := rootInt.(*x509.Certificate) - if !ok { - return errors.Errorf("error parsing %s: file is not a root certificate", root) + afterRenew := getAfterRenewFunc(pid, signum) + if ctx.Bool("daemon") { + // Force is always enabled when daemon mode is used + ctx.Set("force", "true") + next := nextRenewDuration(leaf, expiresIn) + return renewer.Daemon(outFile, next, expiresIn, afterRenew) } + // Do not renew if (now - cert.notAfter) > (expiresIn + jitter) + if expiresIn > 0 { + jitter := rand.Int63n(int64(expiresIn / 20)) + if d := leaf.NotAfter.Sub(time.Now()); d > expiresIn+time.Duration(jitter) { + ui.Printf("certificate not renewed: expires in %s\n", d.Round(time.Second)) + return nil + } + } + + if _, err := renewer.Renew(outFile); err != nil { + return err + } + + ui.Printf("Your certificate has been saved in %s.\n", outFile) + return afterRenew() +} + +func nextRenewDuration(leaf *x509.Certificate, expiresIn time.Duration) time.Duration { + period := leaf.NotAfter.Sub(leaf.NotBefore) + if expiresIn == 0 { + expiresIn = period / 3 + } + + d := leaf.NotAfter.Sub(time.Now()) - expiresIn + n := rand.Int63n(int64(period / 20)) + d -= time.Duration(n) + if d < 0 { + d = 0 + } + return d +} + +func getAfterRenewFunc(pid, signum int) func() error { + if pid == 0 { + return func() error { return nil } + } + + return func() error { + if err := syscall.Kill(pid, syscall.Signal(signum)); err != nil { + return errors.Wrapf(err, "kill %d with signal %d failed", pid, signum) + } + return nil + } +} + +type renewer struct { + client *ca.Client + transport *http.Transport + keyFile string +} + +func newRenewer(caURL, crtFile, keyFile, rootFile string) (*renewer, error) { + cert, err := tls.LoadX509KeyPair(crtFile, keyFile) + if err != nil { + return nil, errors.Wrap(err, "error loading certificates") + } + if len(cert.Certificate) == 0 { + return nil, errors.New("error loading certificate: certificate chain is empty") + } + + rootCert, err := pemutil.ReadCertificate(rootFile) + if err != nil { + return nil, err + } pool := x509.NewCertPool() pool.AddCert(rootCert) @@ -169,28 +267,92 @@ func renewCertificateAction(ctx *cli.Context) error { client, err := ca.NewClient(caURL, ca.WithTransport(tr)) if err != nil { - return err + return nil, err } - resp, err := client.Renew(tr) + return &renewer{ + client: client, + transport: tr, + keyFile: keyFile, + }, nil +} + +func (r *renewer) Renew(outFile string) (*api.SignResponse, error) { + resp, err := r.client.Renew(r.transport) if err != nil { - return errors.Wrap(err, "error renewing certificate") + return nil, errors.Wrap(err, "error renewing certificate") } serverBlock, err := pemutil.Serialize(resp.ServerPEM.Certificate) if err != nil { - return err + return nil, err } caBlock, err := pemutil.Serialize(resp.CaPEM.Certificate) if err != nil { - return err + return nil, err } data := append(pem.EncodeToMemory(serverBlock), pem.EncodeToMemory(caBlock)...) if err := utils.WriteFile(outFile, data, 0600); err != nil { - return errs.FileError(err, outFile) + return nil, errs.FileError(err, outFile) } - ui.Printf("Your certificate has been saved in %s.\n", outFile) - return nil + return resp, nil +} + +func (r *renewer) RenewAndPrepareNext(outFile string, expiresIn time.Duration) (time.Duration, error) { + resp, err := r.Renew(outFile) + if err != nil { + return 0, err + } + + cert, err := tls.LoadX509KeyPair(outFile, r.keyFile) + if err != nil { + return 0, errors.Wrap(err, "error loading certificates") + } + if len(cert.Certificate) == 0 { + return 0, errors.New("error loading certificate: certificate chain is empty") + } + + // Prepare next transport + r.transport.TLSClientConfig.Certificates = []tls.Certificate{cert} + + // Get next renew duration + return nextRenewDuration(resp.ServerPEM.Certificate, expiresIn), nil +} + +func (r *renewer) Daemon(outFile string, next, expiresIn time.Duration, afterRenew func() error) error { + // Loggers + Info := log.New(os.Stdout, "INFO: ", log.LstdFlags) + Error := log.New(os.Stderr, "ERROR: ", log.LstdFlags) + + // Daemon loop + signals := make(chan os.Signal, 1) + signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) + defer signal.Stop(signals) + + for { + select { + case sig := <-signals: + switch sig { + case syscall.SIGHUP: + if n, err := r.RenewAndPrepareNext(outFile, expiresIn); err != nil { + Error.Println(err) + } else { + next = n + Info.Printf("certificate renewed, next in %s", next.Round(time.Second)) + } + case syscall.SIGINT, syscall.SIGTERM: + return nil + } + case <-time.After(next): + if n, err := r.RenewAndPrepareNext(outFile, expiresIn); err != nil { + next = n + Error.Println(err) + } else { + next = n + Info.Printf("certificate renewed, next in %s", next.Round(time.Second)) + } + } + } } From df75da5fe7fe678ac7bf4e4c31964020f5d5a514 Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Mon, 10 Dec 2018 19:33:21 -0800 Subject: [PATCH 05/12] Add fixed period to run the renewal every x duration. --- command/ca/renew.go | 69 ++++++++++++++++++++++++++++++++------------- 1 file changed, 49 insertions(+), 20 deletions(-) diff --git a/command/ca/renew.go b/command/ca/renew.go index 233342a4..94dbea60 100644 --- a/command/ca/renew.go +++ b/command/ca/renew.go @@ -76,6 +76,11 @@ Renew skipped because it was too early: ''' $ step ca renew --expires-in 8h internal.crt internal.key certificate not renewed: expires in 10h52m5s +''' + +Renew the certificate every 16h: +''' +$ step ca renew --daemon --renew-period 16h internal.crt internal.key '''`, Flags: []cli.Flag{ caURLFlag, @@ -95,14 +100,6 @@ as "300ms", "-1.5h" or "2h45m". Valid time units are "ns", "us" (or "µs"), "ms" "s", "m", "h".`, }, flags.Force, - cli.BoolFlag{ - Name: "daemon", - Usage: `Run the renew command as a daemon, renewing the certificate when required, and -overwriting it if it's necessary. By default it will automatically renew the -certificate after 2/3 of the time to expire has passed. This behavior can be -changed using the flag **--expires-in**, in this case, it will only renew the -certificate if the time to expiration is greater than the passed one.`, - }, cli.IntFlag{ Name: "pid", Usage: `The process id to signal after the certificate has been renewed. By default it @@ -115,6 +112,17 @@ flag.`, configuration and load the new certificate.`, Value: int(syscall.SIGHUP), }, + cli.BoolFlag{ + Name: "daemon", + Usage: `Run the renew command as a daemon, renewing the certificate when required, and +overwriting it if it's necessary. By default it will automatically renew the +certificate after 2/3 of the time to expire has passed. This behavior can be +changed using the flags **--renew-period** or **--expires-in**.`, + }, + cli.StringFlag{ + Name: "renew-period", + Usage: `The period to renew the certificate on the daemon mode.`, + }, }, } } @@ -144,12 +152,20 @@ func renewCertificateAction(ctx *cli.Context) error { return errs.RequiredFlag(ctx, "ca-url") } - var expiresIn time.Duration + var expiresIn, renewPeriod time.Duration if s := ctx.String("expires-in"); len(s) > 0 { if expiresIn, err = time.ParseDuration(s); err != nil { return errs.InvalidFlagValue(ctx, "expires-in", s, "") } } + if s := ctx.String("renew-period"); len(s) > 0 { + if renewPeriod, err = time.ParseDuration(s); err != nil { + return errs.InvalidFlagValue(ctx, "renew-period", s, "") + } + } + if expiresIn > 0 && renewPeriod > 0 { + return errs.IncompatibleFlagWithFlag(ctx, "expires-in", "renew-period") + } pid := ctx.Int("pid") if ctx.IsSet("pid") && pid <= 0 { @@ -186,8 +202,8 @@ func renewCertificateAction(ctx *cli.Context) error { if ctx.Bool("daemon") { // Force is always enabled when daemon mode is used ctx.Set("force", "true") - next := nextRenewDuration(leaf, expiresIn) - return renewer.Daemon(outFile, next, expiresIn, afterRenew) + next := nextRenewDuration(leaf, expiresIn, renewPeriod) + return renewer.Daemon(outFile, next, expiresIn, renewPeriod, afterRenew) } // Do not renew if (now - cert.notAfter) > (expiresIn + jitter) @@ -207,7 +223,11 @@ func renewCertificateAction(ctx *cli.Context) error { return afterRenew() } -func nextRenewDuration(leaf *x509.Certificate, expiresIn time.Duration) time.Duration { +func nextRenewDuration(leaf *x509.Certificate, expiresIn, renewPeriod time.Duration) time.Duration { + if renewPeriod > 0 { + return renewPeriod + } + period := leaf.NotAfter.Sub(leaf.NotBefore) if expiresIn == 0 { expiresIn = period / 3 @@ -300,28 +320,30 @@ func (r *renewer) Renew(outFile string) (*api.SignResponse, error) { return resp, nil } -func (r *renewer) RenewAndPrepareNext(outFile string, expiresIn time.Duration) (time.Duration, error) { +func (r *renewer) RenewAndPrepareNext(outFile string, expiresIn, renewPeriod time.Duration) (time.Duration, error) { + const durationOnErrors = 1 * time.Minute + resp, err := r.Renew(outFile) if err != nil { - return 0, err + return durationOnErrors, err } cert, err := tls.LoadX509KeyPair(outFile, r.keyFile) if err != nil { - return 0, errors.Wrap(err, "error loading certificates") + return durationOnErrors, errors.Wrap(err, "error loading certificates") } if len(cert.Certificate) == 0 { - return 0, errors.New("error loading certificate: certificate chain is empty") + return durationOnErrors, errors.New("error loading certificate: certificate chain is empty") } // Prepare next transport r.transport.TLSClientConfig.Certificates = []tls.Certificate{cert} // Get next renew duration - return nextRenewDuration(resp.ServerPEM.Certificate, expiresIn), nil + return nextRenewDuration(resp.ServerPEM.Certificate, expiresIn, renewPeriod), nil } -func (r *renewer) Daemon(outFile string, next, expiresIn time.Duration, afterRenew func() error) error { +func (r *renewer) Daemon(outFile string, next, expiresIn, renewPeriod time.Duration, afterRenew func() error) error { // Loggers Info := log.New(os.Stdout, "INFO: ", log.LstdFlags) Error := log.New(os.Stderr, "ERROR: ", log.LstdFlags) @@ -331,27 +353,34 @@ func (r *renewer) Daemon(outFile string, next, expiresIn time.Duration, afterRen signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) defer signal.Stop(signals) + Info.Printf("first renewal in %s", next.Round(time.Second)) for { select { case sig := <-signals: switch sig { case syscall.SIGHUP: - if n, err := r.RenewAndPrepareNext(outFile, expiresIn); err != nil { + if n, err := r.RenewAndPrepareNext(outFile, expiresIn, renewPeriod); err != nil { Error.Println(err) } else { next = n Info.Printf("certificate renewed, next in %s", next.Round(time.Second)) + if err := afterRenew(); err != nil { + Error.Println(err) + } } case syscall.SIGINT, syscall.SIGTERM: return nil } case <-time.After(next): - if n, err := r.RenewAndPrepareNext(outFile, expiresIn); err != nil { + if n, err := r.RenewAndPrepareNext(outFile, expiresIn, renewPeriod); err != nil { next = n Error.Println(err) } else { next = n Info.Printf("certificate renewed, next in %s", next.Round(time.Second)) + if err := afterRenew(); err != nil { + Error.Println(err) + } } } } From 2df142c43d44b223efcfb37ae68f4cc294e15ab0 Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Tue, 11 Dec 2018 12:01:55 -0800 Subject: [PATCH 06/12] Add examples and some checks for the --renew-period flag. Fixes #44 --- command/ca/renew.go | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/command/ca/renew.go b/command/ca/renew.go index 94dbea60..09010f87 100644 --- a/command/ca/renew.go +++ b/command/ca/renew.go @@ -38,6 +38,13 @@ func renewCertificateCommand() cli.Command { **step ca renew** command renews the given certificates on the certificate authority and writes the new certificate to disk either overwriting or using a new file if the **--out**= flag is used. +å +With the **--daemon** flag the command will update periodically the given +certificate. By default it will renew the certificate before 2/3 of the validity +time of the certificate has passed. It uses a random jitter to avoid multiple +instances running at the same time. The time before the expiration can be +changed using the **--expires-in** flag, or a fixed period can be used with the +**--renew-period** flag. ## POSITIONAL ARGUMENTS @@ -78,6 +85,16 @@ $ step ca renew --expires-in 8h internal.crt internal.key certificate not renewed: expires in 10h52m5s ''' +Renew the certificate before 2/3 of the validity has passed: +''' +$ step ca renew --daemon internal.crt internal.key +''' + +Renew the certificate before 8 hours and 30m of the expiration time: +''' +$ step ca renew --daemon --expires-in 8h30m internal.crt internal.key +''' + Renew the certificate every 16h: ''' $ step ca renew --daemon --renew-period 16h internal.crt internal.key @@ -116,7 +133,7 @@ configuration and load the new certificate.`, Name: "daemon", Usage: `Run the renew command as a daemon, renewing the certificate when required, and overwriting it if it's necessary. By default it will automatically renew the -certificate after 2/3 of the time to expire has passed. This behavior can be +certificate before 2/3 of the time to expire has passed. This behavior can be changed using the flags **--renew-period** or **--expires-in**.`, }, cli.StringFlag{ @@ -136,6 +153,7 @@ func renewCertificateAction(ctx *cli.Context) error { args := ctx.Args() crtFile := args.Get(0) keyFile := args.Get(1) + isDaemon := ctx.Bool("daemon") outFile := ctx.String("out") if len(outFile) == 0 { @@ -166,6 +184,9 @@ func renewCertificateAction(ctx *cli.Context) error { if expiresIn > 0 && renewPeriod > 0 { return errs.IncompatibleFlagWithFlag(ctx, "expires-in", "renew-period") } + if renewPeriod > 0 && !isDaemon { + return errs.RequiredWithFlag(ctx, "renew-period", "daemon") + } pid := ctx.Int("pid") if ctx.IsSet("pid") && pid <= 0 { @@ -192,6 +213,9 @@ func renewCertificateAction(ctx *cli.Context) error { if leaf.NotAfter.Before(time.Now()) { return errors.New("cannot renew an expired certificate") } + if renewPeriod > 0 && renewPeriod >= leaf.NotAfter.Sub(leaf.NotBefore) { + return errors.New("flag '--renew-period' must be lower than the certificate validity") + } renewer, err := newRenewer(caURL, crtFile, keyFile, rootFile) if err != nil { @@ -199,7 +223,7 @@ func renewCertificateAction(ctx *cli.Context) error { } afterRenew := getAfterRenewFunc(pid, signum) - if ctx.Bool("daemon") { + if isDaemon { // Force is always enabled when daemon mode is used ctx.Set("force", "true") next := nextRenewDuration(leaf, expiresIn, renewPeriod) @@ -225,6 +249,10 @@ func renewCertificateAction(ctx *cli.Context) error { func nextRenewDuration(leaf *x509.Certificate, expiresIn, renewPeriod time.Duration) time.Duration { if renewPeriod > 0 { + // Renew now if it will be expired in renewPeriod + if (leaf.NotAfter.Sub(time.Now()) - renewPeriod) <= 0 { + return 0 + } return renewPeriod } From 7a3b673341dc3068691a096cd6dcb37b8f39a494 Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Tue, 11 Dec 2018 14:15:13 -0800 Subject: [PATCH 07/12] Remove extra character. --- command/ca/renew.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/command/ca/renew.go b/command/ca/renew.go index 09010f87..8db30c5d 100644 --- a/command/ca/renew.go +++ b/command/ca/renew.go @@ -38,7 +38,7 @@ func renewCertificateCommand() cli.Command { **step ca renew** command renews the given certificates on the certificate authority and writes the new certificate to disk either overwriting or using a new file if the **--out**= flag is used. -å + With the **--daemon** flag the command will update periodically the given certificate. By default it will renew the certificate before 2/3 of the validity time of the certificate has passed. It uses a random jitter to avoid multiple From 25e7bc2a0eebb07cf0899ee420dd282cc21b7123 Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Tue, 11 Dec 2018 16:51:46 -0800 Subject: [PATCH 08/12] Add support for running a custom command after the renew. Fixes #44 --- command/ca/renew.go | 42 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/command/ca/renew.go b/command/ca/renew.go index 8db30c5d..58a86ecf 100644 --- a/command/ca/renew.go +++ b/command/ca/renew.go @@ -8,8 +8,10 @@ import ( "math/rand" "net/http" "os" + "os/exec" "os/signal" "strconv" + "strings" "syscall" "time" @@ -129,6 +131,10 @@ flag.`, configuration and load the new certificate.`, Value: int(syscall.SIGHUP), }, + cli.StringFlag{ + Name: "exec", + Usage: "The to run after the certificate has been renewed.", + }, cli.BoolFlag{ Name: "daemon", Usage: `Run the renew command as a daemon, renewing the certificate when required, and @@ -154,6 +160,7 @@ func renewCertificateAction(ctx *cli.Context) error { crtFile := args.Get(0) keyFile := args.Get(1) isDaemon := ctx.Bool("daemon") + execCmd := ctx.String("exec") outFile := ctx.String("out") if len(outFile) == 0 { @@ -222,7 +229,7 @@ func renewCertificateAction(ctx *cli.Context) error { return err } - afterRenew := getAfterRenewFunc(pid, signum) + afterRenew := getAfterRenewFunc(pid, signum, execCmd) if isDaemon { // Force is always enabled when daemon mode is used ctx.Set("force", "true") @@ -270,17 +277,36 @@ func nextRenewDuration(leaf *x509.Certificate, expiresIn, renewPeriod time.Durat return d } -func getAfterRenewFunc(pid, signum int) func() error { - if pid == 0 { - return func() error { return nil } - } - +func getAfterRenewFunc(pid, signum int, execCmd string) func() error { return func() error { - if err := syscall.Kill(pid, syscall.Signal(signum)); err != nil { - return errors.Wrapf(err, "kill %d with signal %d failed", pid, signum) + if err := runKillPid(pid, signum); err != nil { + return err } + return runExecCmd(execCmd) + } +} + +func runKillPid(pid, signum int) error { + if pid == 0 { return nil } + if err := syscall.Kill(pid, syscall.Signal(signum)); err != nil { + return errors.Wrapf(err, "kill %d with signal %d failed", pid, signum) + } + return nil +} + +func runExecCmd(execCmd string) error { + execCmd = strings.TrimSpace(execCmd) + if execCmd == "" { + return nil + } + parts := strings.Split(execCmd, " ") + cmd := exec.Command(parts[0], parts[1:]...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() } type renewer struct { From 35e3906f731a2964eb8e0c5fbae7ff71d435fdfe Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Tue, 11 Dec 2018 17:01:02 -0800 Subject: [PATCH 09/12] Add support for running custom commands after a renewal. Fixes #44 --- command/ca/renew.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/command/ca/renew.go b/command/ca/renew.go index 58a86ecf..9bb35ae7 100644 --- a/command/ca/renew.go +++ b/command/ca/renew.go @@ -48,6 +48,9 @@ instances running at the same time. The time before the expiration can be changed using the **--expires-in** flag, or a fixed period can be used with the **--renew-period** flag. +The **--daemon** flag can be combined with **--pid**, **--signal**, or **--exec** +to provide certificate reloads on your services. + ## POSITIONAL ARGUMENTS @@ -100,6 +103,18 @@ $ step ca renew --daemon --expires-in 8h30m internal.crt internal.key Renew the certificate every 16h: ''' $ step ca renew --daemon --renew-period 16h internal.crt internal.key +''' + +Renew the certificate and reload nginx: +''' +$ step ca renew --daemon --exec "nginx -s reload" internal.crt internal.key +''' + +Renew the certificate and convert it to DER: +''' +$ step ca renew --daemon --renew-period 16h \ + --exec "step certificate format --force --out internal.der internal.crt" \ + internal.crt internal.key '''`, Flags: []cli.Flag{ caURLFlag, From 1df747d2768074dbd860b44e45798eba70df5fe0 Mon Sep 17 00:00:00 2001 From: max furman Date: Tue, 11 Dec 2018 17:42:39 -0800 Subject: [PATCH 10/12] documentation adjustments and use ReadCertPool --- command/ca/renew.go | 64 +++++++++++++++++++++++---------------------- 1 file changed, 33 insertions(+), 31 deletions(-) diff --git a/command/ca/renew.go b/command/ca/renew.go index 9bb35ae7..d94ed9e8 100644 --- a/command/ca/renew.go +++ b/command/ca/renew.go @@ -21,6 +21,7 @@ import ( "github.com/smallstep/cli/command" "github.com/smallstep/cli/crypto/pemutil" "github.com/smallstep/cli/crypto/pki" + "github.com/smallstep/cli/crypto/x509util" "github.com/smallstep/cli/errs" "github.com/smallstep/cli/flags" "github.com/smallstep/cli/ui" @@ -37,16 +38,16 @@ func renewCertificateCommand() cli.Command { [**--ca-url**=] [**--root**=] [**--out**=] [**--expires-in**=] [**--force**]`, Description: ` -**step ca renew** command renews the given certificates on the certificate -authority and writes the new certificate to disk either overwriting -or using a new file if the **--out**= flag is used. +**step ca renew** command renews the given certificate (with a request to the +certificate authority) and writes the new certificate to disk - either overwriting + or using a new file when the **--out**= flag is used. -With the **--daemon** flag the command will update periodically the given -certificate. By default it will renew the certificate before 2/3 of the validity -time of the certificate has passed. It uses a random jitter to avoid multiple -instances running at the same time. The time before the expiration can be -changed using the **--expires-in** flag, or a fixed period can be used with the -**--renew-period** flag. +With the **--daemon** flag the command will periodically update the given +certificate. By default, it will renew the certificate before 2/3 of the validity +period of the certificate has elapsed. A random jitter is used to avoid multiple +instances running at the same time. The amount of time between renewal and +certificate expiration can be configured using the **--expires-in** flag, or a +fixed period can be set with the **--renew-period** flag. The **--daemon** flag can be combined with **--pid**, **--signal**, or **--exec** to provide certificate reloads on your services. @@ -125,25 +126,25 @@ $ step ca renew --daemon --renew-period 16h \ }, cli.StringFlag{ Name: "expires-in", - Usage: `The check that will be performed before renewing the certificate. The -certificate renew will be skipped if the time to expiration is greater than the -passed one. A random jitter (duration/20) will be added to avoid multiple -services hitting the renew endpoint at the same time. The is a -sequence of decimal numbers, each with optional fraction and a unit suffix, such -as "300ms", "-1.5h" or "2h45m". Valid time units are "ns", "us" (or "µs"), "ms", -"s", "m", "h".`, + Usage: `The amount of time remaining before certificate expiration, +at which point a renewal should be attempted. The certificate renewal will not +be performed if the time to expiration is greater than the **--expires-in** value. +A random jitter (duration/20) will be added to avoid multiple services hitting the +renew endpoint at the same time. The is a sequence of decimal numbers, +each with optional fraction and a unit suffix, such as "300ms", "-1.5h" or "2h45m". +Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h".`, }, flags.Force, cli.IntFlag{ Name: "pid", - Usage: `The process id to signal after the certificate has been renewed. By default it -will use the SIGHUP (1) signal, but it can be configured with the **--signal** + Usage: `The process id to signal after the certificate has been renewed. By default the +the SIGHUP (1) signal will be used, but this can be configured with the **--signal** flag.`, }, cli.IntFlag{ Name: "signal", Usage: `The signal to send to the selected PID, so it can reload the -configuration and load the new certificate.`, +configuration and load the new certificate. Default value is SIGHUP (1)`, Value: int(syscall.SIGHUP), }, cli.StringFlag{ @@ -152,14 +153,17 @@ configuration and load the new certificate.`, }, cli.BoolFlag{ Name: "daemon", - Usage: `Run the renew command as a daemon, renewing the certificate when required, and -overwriting it if it's necessary. By default it will automatically renew the -certificate before 2/3 of the time to expire has passed. This behavior can be -changed using the flags **--renew-period** or **--expires-in**.`, + Usage: `Run the renew command as a daemon, renewing and overwriting the certificate +periodically. By default the daemon will renew a certificate before 2/3 of the +time to expiration has elapsed. The period can be configured using the +**--renew-period** or **--expires-in** flags.`, }, cli.StringFlag{ - Name: "renew-period", - Usage: `The period to renew the certificate on the daemon mode.`, + Name: "renew-period", + Usage: `The period with which to schedule renewals of the certificate in daemon mode. +Requires the **--daemon** flag. The is a sequence of decimal numbers, +each with optional fraction and a unit suffix, such as "300ms", "1.5h", or "2h45m". +Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h".`, }, }, } @@ -252,7 +256,7 @@ func renewCertificateAction(ctx *cli.Context) error { return renewer.Daemon(outFile, next, expiresIn, renewPeriod, afterRenew) } - // Do not renew if (now - cert.notAfter) > (expiresIn + jitter) + // Do not renew if (cert.notAfter - now) > (expiresIn + jitter) if expiresIn > 0 { jitter := rand.Int63n(int64(expiresIn / 20)) if d := leaf.NotAfter.Sub(time.Now()); d > expiresIn+time.Duration(jitter) { @@ -339,17 +343,15 @@ func newRenewer(caURL, crtFile, keyFile, rootFile string) (*renewer, error) { return nil, errors.New("error loading certificate: certificate chain is empty") } - rootCert, err := pemutil.ReadCertificate(rootFile) + rootCAs, err := x509util.ReadCertPool(rootFile) if err != nil { - return nil, err + return nil, errors.Wrapf(err, "load root certificate pool (from %s) error", rootFile) } - pool := x509.NewCertPool() - pool.AddCert(rootCert) tr := &http.Transport{ TLSClientConfig: &tls.Config{ Certificates: []tls.Certificate{cert}, - RootCAs: pool, + RootCAs: rootCAs, PreferServerCipherSuites: true, }, } From 0bb83a1bcb1c3548148ce61dc32859ef059ed660 Mon Sep 17 00:00:00 2001 From: max furman Date: Tue, 11 Dec 2018 20:22:36 -0800 Subject: [PATCH 11/12] white space and don't wrap error --- command/ca/renew.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/command/ca/renew.go b/command/ca/renew.go index d94ed9e8..bd073b1b 100644 --- a/command/ca/renew.go +++ b/command/ca/renew.go @@ -108,14 +108,14 @@ $ step ca renew --daemon --renew-period 16h internal.crt internal.key Renew the certificate and reload nginx: ''' -$ step ca renew --daemon --exec "nginx -s reload" internal.crt internal.key +$ step ca renew --daemon --exec "nginx -s reload" internal.crt internal.key ''' Renew the certificate and convert it to DER: ''' $ step ca renew --daemon --renew-period 16h \ --exec "step certificate format --force --out internal.der internal.crt" \ - internal.crt internal.key + internal.crt internal.key '''`, Flags: []cli.Flag{ caURLFlag, @@ -345,7 +345,7 @@ func newRenewer(caURL, crtFile, keyFile, rootFile string) (*renewer, error) { rootCAs, err := x509util.ReadCertPool(rootFile) if err != nil { - return nil, errors.Wrapf(err, "load root certificate pool (from %s) error", rootFile) + return nil, err } tr := &http.Transport{ From 0248d66ddd558f073580ea5c19db07693895574e Mon Sep 17 00:00:00 2001 From: max furman Date: Tue, 11 Dec 2018 20:31:35 -0800 Subject: [PATCH 12/12] add context to renew-period error msg --- command/ca/renew.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/command/ca/renew.go b/command/ca/renew.go index bd073b1b..771ecb5c 100644 --- a/command/ca/renew.go +++ b/command/ca/renew.go @@ -239,8 +239,10 @@ func renewCertificateAction(ctx *cli.Context) error { if leaf.NotAfter.Before(time.Now()) { return errors.New("cannot renew an expired certificate") } - if renewPeriod > 0 && renewPeriod >= leaf.NotAfter.Sub(leaf.NotBefore) { - return errors.New("flag '--renew-period' must be lower than the certificate validity") + cvp := leaf.NotAfter.Sub(leaf.NotBefore) + if renewPeriod > 0 && renewPeriod >= cvp { + return errors.Errorf("flag '--renew-period' must be within (lower than) the certificate "+ + "validity period; renew-period=%v, cert-validity-period=%v", renewPeriod, cvp) } renewer, err := newRenewer(caURL, crtFile, keyFile, rootFile)