Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Synchronous CLI command for root CA rotation #48

Merged
merged 3 commits into from
May 16, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
},
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add

Tags: map[string]string{"version": "1.30"},

like we have on cli/command/system/prune.go#L31, so that the command is hidden when talking to an older daemon, and produces an error that it requires a newer daemon

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