diff --git a/api/swagger.yaml b/api/swagger.yaml index 01f9d5d233..12ae9d3720 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -1835,6 +1835,9 @@ definitions: type: "object" additionalProperties: type: "string" + CACert: + description: "The root CA certificate (in PEM format) this external CA uses to issue TLS certificates (assumed to be to the current swarm root CA certificate if not provided)." + type: "string" EncryptionConfig: description: "Parameters related to encryption-at-rest." type: "object" diff --git a/api/types/swarm/swarm.go b/api/types/swarm/swarm.go index c513274750..c989e15725 100644 --- a/api/types/swarm/swarm.go +++ b/api/types/swarm/swarm.go @@ -126,6 +126,10 @@ type ExternalCA struct { // Options is a set of additional key/value pairs whose interpretation // depends on the specified CA type. Options map[string]string `json:",omitempty"` + + // CACert specifies which root CA is used by this external CA. This certificate must + // be in PEM format. + CACert string } // InitRequest is the request used to init a swarm. diff --git a/cli/command/swarm/opts.go b/cli/command/swarm/opts.go index 6eddddccae..75b92d49c3 100644 --- a/cli/command/swarm/opts.go +++ b/cli/command/swarm/opts.go @@ -2,7 +2,9 @@ package swarm import ( "encoding/csv" + "encoding/pem" "fmt" + "io/ioutil" "strings" "time" @@ -155,6 +157,15 @@ func parseExternalCA(caSpec string) (*swarm.ExternalCA, error) { case "url": hasURL = true externalCA.URL = value + case "cacert": + cacontents, err := ioutil.ReadFile(value) + if err != nil { + return nil, errors.Wrap(err, "unable to read CA cert for external CA") + } + if pemBlock, _ := pem.Decode(cacontents); pemBlock == nil { + return nil, errors.New("CA cert for external CA must be in PEM format") + } + externalCA.CACert = string(cacontents) default: externalCA.Options[key] = value } diff --git a/daemon/cluster/convert/swarm.go b/daemon/cluster/convert/swarm.go index 98e0ce25e6..09121fc8ff 100644 --- a/daemon/cluster/convert/swarm.go +++ b/daemon/cluster/convert/swarm.go @@ -47,6 +47,7 @@ func SwarmFromGRPC(c swarmapi.Cluster) types.Swarm { Protocol: types.ExternalCAProtocol(strings.ToLower(ca.Protocol.String())), URL: ca.URL, Options: ca.Options, + CACert: string(ca.CACert), }) } @@ -112,6 +113,7 @@ func MergeSwarmSpecToGRPC(s types.Spec, spec swarmapi.ClusterSpec) (swarmapi.Clu Protocol: swarmapi.ExternalCA_CAProtocol(protocol), URL: ca.URL, Options: ca.Options, + CACert: []byte(ca.CACert), }) } diff --git a/integration-cli/docker_api_swarm_test.go b/integration-cli/docker_api_swarm_test.go index 7b131000c7..ac3e1e538e 100644 --- a/integration-cli/docker_api_swarm_test.go +++ b/integration-cli/docker_api_swarm_test.go @@ -146,9 +146,6 @@ func (s *DockerSwarmSuite) TestAPISwarmJoinToken(c *check.C) { } func (s *DockerSwarmSuite) TestUpdateSwarmAddExternalCA(c *check.C) { - // TODO: when root rotation is in, convert to a series of root rotation tests instead. - // currently just makes sure that we don't have to provide a CA certificate when - // providing an external CA d1 := s.AddDaemon(c, false, false) c.Assert(d1.Init(swarm.InitRequest{}), checker.IsNil) d1.UpdateSwarm(c, func(s *swarm.Spec) { @@ -157,11 +154,18 @@ func (s *DockerSwarmSuite) TestUpdateSwarmAddExternalCA(c *check.C) { Protocol: swarm.ExternalCAProtocolCFSSL, URL: "https://thishasnoca.org", }, + { + Protocol: swarm.ExternalCAProtocolCFSSL, + URL: "https://thishasacacert.org", + CACert: "cacert", + }, } }) info, err := d1.SwarmInfo() c.Assert(err, checker.IsNil) - c.Assert(info.Cluster.Spec.CAConfig.ExternalCAs, checker.HasLen, 1) + c.Assert(info.Cluster.Spec.CAConfig.ExternalCAs, checker.HasLen, 2) + c.Assert(info.Cluster.Spec.CAConfig.ExternalCAs[0].CACert, checker.Equals, "") + c.Assert(info.Cluster.Spec.CAConfig.ExternalCAs[1].CACert, checker.Equals, "cacert") } func (s *DockerSwarmSuite) TestAPISwarmCAHash(c *check.C) { diff --git a/integration-cli/docker_cli_swarm_test.go b/integration-cli/docker_cli_swarm_test.go index 5d79ee9176..c32f9cf52c 100644 --- a/integration-cli/docker_cli_swarm_test.go +++ b/integration-cli/docker_cli_swarm_test.go @@ -23,6 +23,7 @@ import ( "github.com/docker/docker/integration-cli/daemon" "github.com/docker/docker/pkg/testutil" icmd "github.com/docker/docker/pkg/testutil/cmd" + "github.com/docker/docker/pkg/testutil/tempfile" "github.com/docker/libnetwork/driverapi" "github.com/docker/libnetwork/ipamapi" remoteipam "github.com/docker/libnetwork/ipams/remote/api" @@ -53,11 +54,29 @@ func (s *DockerSwarmSuite) TestSwarmUpdate(c *check.C) { c.Assert(spec.CAConfig.NodeCertExpiry, checker.Equals, 30*time.Hour) // passing an external CA (this is without starting a root rotation) does not fail - out, err = d.Cmd("swarm", "update", "--external-ca", "protocol=cfssl,url=https://something.org") - c.Assert(err, checker.IsNil, check.Commentf("out: %v", out)) + cli.Docker(cli.Args("swarm", "update", "--external-ca", "protocol=cfssl,url=https://something.org", + "--external-ca", "protocol=cfssl,url=https://somethingelse.org,cacert=fixtures/https/ca.pem"), + cli.Daemon(d.Daemon)).Assert(c, icmd.Success) + + expected, err := ioutil.ReadFile("fixtures/https/ca.pem") + c.Assert(err, checker.IsNil) spec = getSpec() - c.Assert(spec.CAConfig.ExternalCAs, checker.HasLen, 1) + c.Assert(spec.CAConfig.ExternalCAs, checker.HasLen, 2) + c.Assert(spec.CAConfig.ExternalCAs[0].CACert, checker.Equals, "") + c.Assert(spec.CAConfig.ExternalCAs[1].CACert, checker.Equals, string(expected)) + + // passing an invalid external CA fails + tempFile := tempfile.NewTempFile(c, "testfile", "fakecert") + defer tempFile.Remove() + + result := cli.Docker(cli.Args("swarm", "update", + "--external-ca", fmt.Sprintf("protocol=cfssl,url=https://something.org,cacert=%s", tempFile.Name())), + cli.Daemon(d.Daemon)) + result.Assert(c, icmd.Expected{ + ExitCode: 125, + Err: "must be in PEM format", + }) } func (s *DockerSwarmSuite) TestSwarmInit(c *check.C) { @@ -68,17 +87,34 @@ func (s *DockerSwarmSuite) TestSwarmInit(c *check.C) { return sw.Spec } + // passing an invalid external CA fails + tempFile := tempfile.NewTempFile(c, "testfile", "fakecert") + defer tempFile.Remove() + + result := cli.Docker(cli.Args("swarm", "init", "--cert-expiry", "30h", "--dispatcher-heartbeat", "11s", + "--external-ca", fmt.Sprintf("protocol=cfssl,url=https://somethingelse.org,cacert=%s", tempFile.Name())), + cli.Daemon(d.Daemon)) + result.Assert(c, icmd.Expected{ + ExitCode: 125, + Err: "must be in PEM format", + }) + cli.Docker(cli.Args("swarm", "init", "--cert-expiry", "30h", "--dispatcher-heartbeat", "11s", - "--external-ca", "protocol=cfssl,url=https://something.org"), + "--external-ca", "protocol=cfssl,url=https://something.org", + "--external-ca", "protocol=cfssl,url=https://somethingelse.org,cacert=fixtures/https/ca.pem"), cli.Daemon(d.Daemon)).Assert(c, icmd.Success) + expected, err := ioutil.ReadFile("fixtures/https/ca.pem") + c.Assert(err, checker.IsNil) + spec := getSpec() c.Assert(spec.CAConfig.NodeCertExpiry, checker.Equals, 30*time.Hour) c.Assert(spec.Dispatcher.HeartbeatPeriod, checker.Equals, 11*time.Second) - c.Assert(spec.CAConfig.ExternalCAs, checker.HasLen, 1) + c.Assert(spec.CAConfig.ExternalCAs, checker.HasLen, 2) + c.Assert(spec.CAConfig.ExternalCAs[0].CACert, checker.Equals, "") + c.Assert(spec.CAConfig.ExternalCAs[1].CACert, checker.Equals, string(expected)) c.Assert(d.Leave(true), checker.IsNil) - time.Sleep(500 * time.Millisecond) // https://github.com/docker/swarmkit/issues/1421 cli.Docker(cli.Args("swarm", "init"), cli.Daemon(d.Daemon)).Assert(c, icmd.Success) spec = getSpec()