diff --git a/cli/command/swarm/ca.go b/cli/command/swarm/ca.go new file mode 100644 index 0000000000..0814a53950 --- /dev/null +++ b/cli/command/swarm/ca.go @@ -0,0 +1,134 @@ +package swarm + +import ( + "fmt" + "io" + "strings" + + "golang.org/x/net/context" + + "io/ioutil" + + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/command/swarm/progress" + "github.com/docker/docker/api/types/swarm" + "github.com/docker/docker/pkg/jsonmessage" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +type caOptions struct { + swarmOptions + rootCACert PEMFile + rootCAKey PEMFile + rotate bool + detach bool + quiet bool +} + +func newRotateCACommand(dockerCli command.Cli) *cobra.Command { + opts := caOptions{} + + cmd := &cobra.Command{ + Use: "ca [OPTIONS]", + Short: "Manage root CA", + Args: cli.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runRotateCA(dockerCli, cmd.Flags(), opts) + }, + Tags: map[string]string{"version": "1.30"}, + } + + flags := cmd.Flags() + addSwarmCAFlags(flags, &opts.swarmOptions) + flags.BoolVar(&opts.rotate, flagRotate, false, "Rotate the swarm CA - if no certificate or key are provided, new ones will be generated") + flags.Var(&opts.rootCACert, flagCACert, "Path to the PEM-formatted root CA certificate to use for the new cluster") + flags.Var(&opts.rootCAKey, flagCAKey, "Path to the PEM-formatted root CA key to use for the new cluster") + + flags.BoolVarP(&opts.detach, "detach", "d", false, "Exit immediately instead of waiting for the root rotation to converge") + flags.BoolVarP(&opts.quiet, "quiet", "q", false, "Suppress progress output") + return cmd +} + +func runRotateCA(dockerCli command.Cli, flags *pflag.FlagSet, opts caOptions) error { + client := dockerCli.Client() + ctx := context.Background() + + swarmInspect, err := client.SwarmInspect(ctx) + if err != nil { + return err + } + + if !opts.rotate { + if swarmInspect.ClusterInfo.TLSInfo.TrustRoot == "" { + fmt.Fprintln(dockerCli.Out(), "No CA information available") + } else { + fmt.Fprintln(dockerCli.Out(), strings.TrimSpace(swarmInspect.ClusterInfo.TLSInfo.TrustRoot)) + } + return nil + } + + genRootCA := true + spec := &swarmInspect.Spec + opts.mergeSwarmSpec(spec, flags) + if flags.Changed(flagCACert) { + spec.CAConfig.SigningCACert = opts.rootCACert.Contents() + genRootCA = false + } + if flags.Changed(flagCAKey) { + spec.CAConfig.SigningCAKey = opts.rootCAKey.Contents() + genRootCA = false + } + if genRootCA { + spec.CAConfig.ForceRotate++ + spec.CAConfig.SigningCACert = "" + spec.CAConfig.SigningCAKey = "" + } + + if err := client.SwarmUpdate(ctx, swarmInspect.Version, swarmInspect.Spec, swarm.UpdateFlags{}); err != nil { + return err + } + + if opts.detach { + return nil + } + + errChan := make(chan error, 1) + pipeReader, pipeWriter := io.Pipe() + + go func() { + errChan <- progress.RootRotationProgress(ctx, client, pipeWriter) + }() + + if opts.quiet { + go func() { + for { + if _, err := io.Copy(ioutil.Discard, pipeReader); err != nil { + return + } + } + }() + return <-errChan + } + + err = jsonmessage.DisplayJSONMessagesToStream(pipeReader, dockerCli.Out(), nil) + if err == nil { + err = <-errChan + } + if err != nil { + return err + } + + swarmInspect, err = client.SwarmInspect(ctx) + if err != nil { + return err + } + + if swarmInspect.ClusterInfo.TLSInfo.TrustRoot == "" { + fmt.Fprintln(dockerCli.Out(), "No CA information available") + } else { + fmt.Fprintln(dockerCli.Out(), strings.TrimSpace(swarmInspect.ClusterInfo.TLSInfo.TrustRoot)) + } + return nil +} diff --git a/cli/command/swarm/cmd.go b/cli/command/swarm/cmd.go index 07cb302c62..b7e6dcfda2 100644 --- a/cli/command/swarm/cmd.go +++ b/cli/command/swarm/cmd.go @@ -25,6 +25,7 @@ func NewSwarmCommand(dockerCli command.Cli) *cobra.Command { newUpdateCommand(dockerCli), newLeaveCommand(dockerCli), newUnlockCommand(dockerCli), + newRotateCACommand(dockerCli), ) return cmd } diff --git a/cli/command/swarm/opts.go b/cli/command/swarm/opts.go index 868605e6d0..4625835055 100644 --- a/cli/command/swarm/opts.go +++ b/cli/command/swarm/opts.go @@ -31,6 +31,8 @@ const ( flagSnapshotInterval = "snapshot-interval" flagAutolock = "autolock" flagAvailability = "availability" + flagCACert = "ca-cert" + flagCAKey = "ca-key" ) type swarmOptions struct { @@ -119,6 +121,39 @@ func (m *ExternalCAOption) Value() []*swarm.ExternalCA { return m.values } +// PEMFile represents the path to a pem-formatted file +type PEMFile struct { + path, contents string +} + +// Type returns the type of this option. +func (p *PEMFile) Type() string { + return "pem-file" +} + +// String returns the path to the pem file +func (p *PEMFile) String() string { + return p.path +} + +// Set parses a root rotation option +func (p *PEMFile) Set(value string) error { + contents, err := ioutil.ReadFile(value) + if err != nil { + return err + } + if pemBlock, _ := pem.Decode(contents); pemBlock == nil { + return errors.New("file contents must be in PEM format") + } + p.contents, p.path = string(contents), value + return nil +} + +// Contents returns the contents of the PEM file +func (p *PEMFile) Contents() string { + return p.contents +} + // parseExternalCA parses an external CA specification from the command line, // such as protocol=cfssl,url=https://example.com. func parseExternalCA(caSpec string) (*swarm.ExternalCA, error) { @@ -181,15 +216,19 @@ func parseExternalCA(caSpec string) (*swarm.ExternalCA, error) { return &externalCA, nil } +func addSwarmCAFlags(flags *pflag.FlagSet, opts *swarmOptions) { + flags.DurationVar(&opts.nodeCertExpiry, flagCertExpiry, time.Duration(90*24*time.Hour), "Validity period for node certificates (ns|us|ms|s|m|h)") + flags.Var(&opts.externalCA, flagExternalCA, "Specifications of one or more certificate signing endpoints") +} + func addSwarmFlags(flags *pflag.FlagSet, opts *swarmOptions) { flags.Int64Var(&opts.taskHistoryLimit, flagTaskHistoryLimit, 5, "Task history retention limit") flags.DurationVar(&opts.dispatcherHeartbeat, flagDispatcherHeartbeat, time.Duration(5*time.Second), "Dispatcher heartbeat period (ns|us|ms|s|m|h)") - flags.DurationVar(&opts.nodeCertExpiry, flagCertExpiry, time.Duration(90*24*time.Hour), "Validity period for node certificates (ns|us|ms|s|m|h)") - flags.Var(&opts.externalCA, flagExternalCA, "Specifications of one or more certificate signing endpoints") flags.Uint64Var(&opts.maxSnapshots, flagMaxSnapshots, 0, "Number of additional Raft snapshots to retain") flags.SetAnnotation(flagMaxSnapshots, "version", []string{"1.25"}) flags.Uint64Var(&opts.snapshotInterval, flagSnapshotInterval, 10000, "Number of log entries between Raft snapshots") flags.SetAnnotation(flagSnapshotInterval, "version", []string{"1.25"}) + addSwarmCAFlags(flags, opts) } func (opts *swarmOptions) mergeSwarmSpec(spec *swarm.Spec, flags *pflag.FlagSet) { diff --git a/cli/command/swarm/progress/root_rotation.go b/cli/command/swarm/progress/root_rotation.go new file mode 100644 index 0000000000..0b84d239e3 --- /dev/null +++ b/cli/command/swarm/progress/root_rotation.go @@ -0,0 +1,121 @@ +package progress + +import ( + "bytes" + "io" + "os" + "os/signal" + "time" + + "golang.org/x/net/context" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/swarm" + "github.com/docker/docker/client" + "github.com/docker/docker/pkg/progress" + "github.com/docker/docker/pkg/streamformatter" + "github.com/opencontainers/go-digest" +) + +const ( + certsRotatedStr = " rotated TLS certificates" + rootsRotatedStr = " rotated CA certificates" + // rootsAction has a single space because rootsRotatedStr is one character shorter than certsRotatedStr. + // This makes sure the progress bar are aligned. + certsAction = "" + rootsAction = " " +) + +// RootRotationProgress outputs progress information for convergence of a root rotation. +func RootRotationProgress(ctx context.Context, dclient client.APIClient, progressWriter io.WriteCloser) error { + defer progressWriter.Close() + + progressOut := streamformatter.NewJSONProgressOutput(progressWriter, false) + + sigint := make(chan os.Signal, 1) + signal.Notify(sigint, os.Interrupt) + defer signal.Stop(sigint) + + // draw 2 progress bars, 1 for nodes with the correct cert, 1 for nodes with the correct trust root + progress.Update(progressOut, "desired root digest", "") + progress.Update(progressOut, certsRotatedStr, certsAction) + progress.Update(progressOut, rootsRotatedStr, rootsAction) + + var done bool + + for { + info, err := dclient.SwarmInspect(ctx) + if err != nil { + return err + } + + if done { + return nil + } + + nodes, err := dclient.NodeList(ctx, types.NodeListOptions{}) + if err != nil { + return err + } + + done = updateProgress(progressOut, info.ClusterInfo.TLSInfo, nodes, info.ClusterInfo.RootRotationInProgress) + + select { + case <-time.After(200 * time.Millisecond): + case <-sigint: + if !done { + progress.Message(progressOut, "", "Operation continuing in background.") + progress.Message(progressOut, "", "Use `swarmctl cluster inspect default` to check progress.") + } + return nil + } + } +} + +func updateProgress(progressOut progress.Output, desiredTLSInfo swarm.TLSInfo, nodes []swarm.Node, rootRotationInProgress bool) bool { + // write the current desired root cert's digest, because the desired root certs might be too long + progressOut.WriteProgress(progress.Progress{ + ID: "desired root digest", + Action: digest.FromBytes([]byte(desiredTLSInfo.TrustRoot)).String(), + }) + + // If we had reached a converged state, check if we are still converged. + var certsRight, trustRootsRight int64 + for _, n := range nodes { + if bytes.Equal(n.Description.TLSInfo.CertIssuerPublicKey, desiredTLSInfo.CertIssuerPublicKey) && + bytes.Equal(n.Description.TLSInfo.CertIssuerSubject, desiredTLSInfo.CertIssuerSubject) { + certsRight++ + } + + if n.Description.TLSInfo.TrustRoot == desiredTLSInfo.TrustRoot { + trustRootsRight++ + } + } + + total := int64(len(nodes)) + progressOut.WriteProgress(progress.Progress{ + ID: certsRotatedStr, + Action: certsAction, + Current: certsRight, + Total: total, + Units: "nodes", + }) + + rootsProgress := progress.Progress{ + ID: rootsRotatedStr, + Action: rootsAction, + Current: trustRootsRight, + Total: total, + Units: "nodes", + } + + if certsRight == total && !rootRotationInProgress { + progressOut.WriteProgress(rootsProgress) + return certsRight == total && trustRootsRight == total + } + + // we still have certs that need renewing, so display that there are zero roots rotated yet + rootsProgress.Current = 0 + progressOut.WriteProgress(rootsProgress) + return false +} diff --git a/cli/command/system/info.go b/cli/command/system/info.go index efb3c4466b..7b91066974 100644 --- a/cli/command/system/info.go +++ b/cli/command/system/info.go @@ -123,6 +123,8 @@ func prettyPrintInfo(dockerCli *command.DockerCli, info types.Info) error { fmt.Fprintf(dockerCli.Out(), " Heartbeat Period: %s\n", units.HumanDuration(time.Duration(info.Swarm.Cluster.Spec.Dispatcher.HeartbeatPeriod))) fmt.Fprintf(dockerCli.Out(), " CA Configuration:\n") fmt.Fprintf(dockerCli.Out(), " Expiry Duration: %s\n", units.HumanDuration(info.Swarm.Cluster.Spec.CAConfig.NodeCertExpiry)) + fmt.Fprintf(dockerCli.Out(), " Force Rotate: %d\n", info.Swarm.Cluster.Spec.CAConfig.ForceRotate) + fprintfIfNotEmpty(dockerCli.Out(), " Signing CA Certificate: \n%s\n\n", strings.TrimSpace(info.Swarm.Cluster.Spec.CAConfig.SigningCACert)) if len(info.Swarm.Cluster.Spec.CAConfig.ExternalCAs) > 0 { fmt.Fprintf(dockerCli.Out(), " External CAs:\n") for _, entry := range info.Swarm.Cluster.Spec.CAConfig.ExternalCAs {