Skip to content

Commit

Permalink
Merge pull request #48 from cyli/root-rotation-cli
Browse files Browse the repository at this point in the history
Synchronous CLI command for root CA rotation
  • Loading branch information
aaronlehmann authored May 16, 2017
2 parents ebbab14 + 51f6983 commit c17acee
Show file tree
Hide file tree
Showing 11 changed files with 332 additions and 17 deletions.
10 changes: 2 additions & 8 deletions cli/command/service/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package service

import (
"io"
"io/ioutil"

"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/command/service/progress"
Expand All @@ -20,14 +21,7 @@ func waitOnService(ctx context.Context, dockerCli *command.DockerCli, serviceID
}()

if opts.quiet {
go func() {
for {
var buf [1024]byte
if _, err := pipeReader.Read(buf[:]); err != nil {
return
}
}
}()
go io.Copy(ioutil.Discard, pipeReader)
return <-errChan
}

Expand Down
128 changes: 128 additions & 0 deletions cli/command/swarm/ca.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
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 io.Copy(ioutil.Discard, pipeReader)
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
}
1 change: 1 addition & 0 deletions cli/command/swarm/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ func NewSwarmCommand(dockerCli command.Cli) *cobra.Command {
newUpdateCommand(dockerCli),
newLeaveCommand(dockerCli),
newUnlockCommand(dockerCli),
newRotateCACommand(dockerCli),
)
return cmd
}
43 changes: 41 additions & 2 deletions cli/command/swarm/opts.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ const (
flagSnapshotInterval = "snapshot-interval"
flagAutolock = "autolock"
flagAvailability = "availability"
flagCACert = "ca-cert"
flagCAKey = "ca-key"
)

type swarmOptions struct {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
121 changes: 121 additions & 0 deletions cli/command/swarm/progress/root_rotation.go
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 2 additions & 0 deletions cli/command/system/info.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion vendor.conf
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ github.com/agl/ed25519 d2b94fd789ea21d12fac1a4443dd3a3f79cda72c
github.com/coreos/etcd 824277cb3a577a0e8c829ca9ec557b973fe06d20
github.com/davecgh/go-spew 346938d642f2ec3594ed81d874461961cd0faa76
github.com/docker/distribution b38e5838b7b2f2ad48e06ec4b500011976080621
github.com/docker/docker 69c35dad8e7ec21de32d42b9dd606d3416ae1566
github.com/docker/docker eb8abc95985bf3882a4a177c409a96e36e25f5b7
github.com/docker/docker-credential-helpers v0.5.0
github.com/docker/go d30aec9fd63c35133f8f79c3412ad91a3b08be06
github.com/docker/go-connections e15c02316c12de00874640cd76311849de2aeed5
Expand Down
10 changes: 10 additions & 0 deletions vendor/github.com/docker/docker/api/types/swarm/swarm.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit c17acee

Please sign in to comment.