Merge pull request #32993 from cyli/root-rotation-cli
API changes to rotate swarm root CA
This commit is contained in:
commit
eb8abc9598
|
@ -1886,6 +1886,14 @@ definitions:
|
||||||
CACert:
|
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)."
|
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"
|
type: "string"
|
||||||
|
SigningCACert:
|
||||||
|
description: "The desired signing CA certificate for all swarm node TLS leaf certificates, in PEM format."
|
||||||
|
type: "string"
|
||||||
|
SigningCAKey:
|
||||||
|
description: "The desired signing CA key for all swarm node TLS leaf certificates, in PEM format."
|
||||||
|
type: "string"
|
||||||
|
ForceRotate:
|
||||||
|
description: "An integer whose purpose is to force swarm to generate a new signing CA certificate and key, if none have been specified in `SigningCACert` and `SigningCAKey`"
|
||||||
EncryptionConfig:
|
EncryptionConfig:
|
||||||
description: "Parameters related to encryption-at-rest."
|
description: "Parameters related to encryption-at-rest."
|
||||||
type: "object"
|
type: "object"
|
||||||
|
|
|
@ -109,6 +109,16 @@ type CAConfig struct {
|
||||||
// ExternalCAs is a list of CAs to which a manager node will make
|
// ExternalCAs is a list of CAs to which a manager node will make
|
||||||
// certificate signing requests for node certificates.
|
// certificate signing requests for node certificates.
|
||||||
ExternalCAs []*ExternalCA `json:",omitempty"`
|
ExternalCAs []*ExternalCA `json:",omitempty"`
|
||||||
|
|
||||||
|
// SigningCACert and SigningCAKey specify the desired signing root CA and
|
||||||
|
// root CA key for the swarm. When inspecting the cluster, the key will
|
||||||
|
// be redacted.
|
||||||
|
SigningCACert string `json:",omitempty"`
|
||||||
|
SigningCAKey string `json:",omitempty"`
|
||||||
|
|
||||||
|
// If this value changes, and there is no specified signing cert and key,
|
||||||
|
// then the swarm is forced to generate a new root certificate ane key.
|
||||||
|
ForceRotate uint64 `json:",omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExternalCAProtocol represents type of external CA.
|
// ExternalCAProtocol represents type of external CA.
|
||||||
|
|
|
@ -30,6 +30,11 @@ func SwarmFromGRPC(c swarmapi.Cluster) types.Swarm {
|
||||||
EncryptionConfig: types.EncryptionConfig{
|
EncryptionConfig: types.EncryptionConfig{
|
||||||
AutoLockManagers: c.Spec.EncryptionConfig.AutoLockManagers,
|
AutoLockManagers: c.Spec.EncryptionConfig.AutoLockManagers,
|
||||||
},
|
},
|
||||||
|
CAConfig: types.CAConfig{
|
||||||
|
// do not include the signing CA key (it should already be redacted via the swarm APIs)
|
||||||
|
SigningCACert: string(c.Spec.CAConfig.SigningCACert),
|
||||||
|
ForceRotate: c.Spec.CAConfig.ForceRotate,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
TLSInfo: types.TLSInfo{
|
TLSInfo: types.TLSInfo{
|
||||||
TrustRoot: string(c.RootCA.CACert),
|
TrustRoot: string(c.RootCA.CACert),
|
||||||
|
@ -114,6 +119,14 @@ func MergeSwarmSpecToGRPC(s types.Spec, spec swarmapi.ClusterSpec) (swarmapi.Clu
|
||||||
if s.CAConfig.NodeCertExpiry != 0 {
|
if s.CAConfig.NodeCertExpiry != 0 {
|
||||||
spec.CAConfig.NodeCertExpiry = gogotypes.DurationProto(s.CAConfig.NodeCertExpiry)
|
spec.CAConfig.NodeCertExpiry = gogotypes.DurationProto(s.CAConfig.NodeCertExpiry)
|
||||||
}
|
}
|
||||||
|
if s.CAConfig.SigningCACert != "" {
|
||||||
|
spec.CAConfig.SigningCACert = []byte(s.CAConfig.SigningCACert)
|
||||||
|
}
|
||||||
|
if s.CAConfig.SigningCAKey != "" {
|
||||||
|
// do propagate the signing CA key here because we want to provide it TO the swarm APIs
|
||||||
|
spec.CAConfig.SigningCAKey = []byte(s.CAConfig.SigningCAKey)
|
||||||
|
}
|
||||||
|
spec.CAConfig.ForceRotate = s.CAConfig.ForceRotate
|
||||||
|
|
||||||
for _, ca := range s.CAConfig.ExternalCAs {
|
for _, ca := range s.CAConfig.ExternalCAs {
|
||||||
protocol, ok := swarmapi.ExternalCA_CAProtocol_value[strings.ToUpper(string(ca.Protocol))]
|
protocol, ok := swarmapi.ExternalCA_CAProtocol_value[strings.ToUpper(string(ca.Protocol))]
|
||||||
|
|
|
@ -19,11 +19,14 @@ keywords: "API, Docker, rcli, REST, documentation"
|
||||||
|
|
||||||
* `GET /info` now returns the list of supported logging drivers, including plugins.
|
* `GET /info` now returns the list of supported logging drivers, including plugins.
|
||||||
* `GET /info` and `GET /swarm` now returns the cluster-wide swarm CA info if the node is in a swarm: the cluster root CA certificate, and the cluster TLS
|
* `GET /info` and `GET /swarm` now returns the cluster-wide swarm CA info if the node is in a swarm: the cluster root CA certificate, and the cluster TLS
|
||||||
leaf certificate issuer's subject and public key.
|
leaf certificate issuer's subject and public key. It also displays the desired CA signing certificate, if any was provided as part of the spec.
|
||||||
* `POST /build/` now (when not silent) produces an `Aux` message in the JSON output stream with payload `types.BuildResult` for each image produced. The final such message will reference the image resulting from the build.
|
* `POST /build/` now (when not silent) produces an `Aux` message in the JSON output stream with payload `types.BuildResult` for each image produced. The final such message will reference the image resulting from the build.
|
||||||
* `GET /nodes` and `GET /nodes/{id}` now returns additional information about swarm TLS info if the node is part of a swarm: the trusted root CA, and the
|
* `GET /nodes` and `GET /nodes/{id}` now returns additional information about swarm TLS info if the node is part of a swarm: the trusted root CA, and the
|
||||||
issuer's subject and public key.
|
issuer's subject and public key.
|
||||||
* `GET /distribution/(name)/json` is a new endpoint that returns a JSON output stream with payload `types.DistributionInspect` for an image name. It includes a descriptor with the digest, and supported platforms retrieved from directly contacting the registry.
|
* `GET /distribution/(name)/json` is a new endpoint that returns a JSON output stream with payload `types.DistributionInspect` for an image name. It includes a descriptor with the digest, and supported platforms retrieved from directly contacting the registry.
|
||||||
|
* `POST /swarm/update` now accepts 3 additional parameters as part of the swarm spec's CA configuration; the desired CA certificate for
|
||||||
|
the swarm, the desired CA key for the swarm (if not using an external certificate), and an optional parameter to force swarm to
|
||||||
|
generate and rotate to a new CA certificate/key pair.
|
||||||
|
|
||||||
## v1.29 API changes
|
## v1.29 API changes
|
||||||
|
|
||||||
|
|
|
@ -14,12 +14,15 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/cloudflare/cfssl/csr"
|
||||||
"github.com/cloudflare/cfssl/helpers"
|
"github.com/cloudflare/cfssl/helpers"
|
||||||
|
"github.com/cloudflare/cfssl/initca"
|
||||||
"github.com/docker/docker/api/types"
|
"github.com/docker/docker/api/types"
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/swarm"
|
"github.com/docker/docker/api/types/swarm"
|
||||||
"github.com/docker/docker/integration-cli/checker"
|
"github.com/docker/docker/integration-cli/checker"
|
||||||
"github.com/docker/docker/integration-cli/daemon"
|
"github.com/docker/docker/integration-cli/daemon"
|
||||||
|
"github.com/docker/swarmkit/ca"
|
||||||
"github.com/go-check/check"
|
"github.com/go-check/check"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -930,3 +933,72 @@ func (s *DockerSwarmSuite) TestAPISwarmHealthcheckNone(c *check.C) {
|
||||||
out, err = d.Cmd("exec", containers[0], "ping", "-c1", "-W3", "top")
|
out, err = d.Cmd("exec", containers[0], "ping", "-c1", "-W3", "top")
|
||||||
c.Assert(err, checker.IsNil, check.Commentf(out))
|
c.Assert(err, checker.IsNil, check.Commentf(out))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *DockerSwarmSuite) TestSwarmRepeatedRootRotation(c *check.C) {
|
||||||
|
m := s.AddDaemon(c, true, true)
|
||||||
|
w := s.AddDaemon(c, true, false)
|
||||||
|
|
||||||
|
info, err := m.SwarmInfo()
|
||||||
|
c.Assert(err, checker.IsNil)
|
||||||
|
|
||||||
|
currentTrustRoot := info.Cluster.TLSInfo.TrustRoot
|
||||||
|
|
||||||
|
// rotate multiple times
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
var cert, key []byte
|
||||||
|
if i%2 != 0 {
|
||||||
|
cert, _, key, err = initca.New(&csr.CertificateRequest{
|
||||||
|
CN: "newRoot",
|
||||||
|
KeyRequest: csr.NewBasicKeyRequest(),
|
||||||
|
CA: &csr.CAConfig{Expiry: ca.RootCAExpiration},
|
||||||
|
})
|
||||||
|
c.Assert(err, checker.IsNil)
|
||||||
|
}
|
||||||
|
expectedCert := string(cert)
|
||||||
|
m.UpdateSwarm(c, func(s *swarm.Spec) {
|
||||||
|
s.CAConfig.SigningCACert = expectedCert
|
||||||
|
s.CAConfig.SigningCAKey = string(key)
|
||||||
|
s.CAConfig.ForceRotate++
|
||||||
|
})
|
||||||
|
|
||||||
|
// poll to make sure update succeeds
|
||||||
|
var clusterTLSInfo swarm.TLSInfo
|
||||||
|
for j := 0; j < 18; j++ {
|
||||||
|
info, err := m.SwarmInfo()
|
||||||
|
c.Assert(err, checker.IsNil)
|
||||||
|
c.Assert(info.Cluster.Spec.CAConfig.SigningCACert, checker.Equals, expectedCert)
|
||||||
|
// the desired CA key is always redacted
|
||||||
|
c.Assert(info.Cluster.Spec.CAConfig.SigningCAKey, checker.Equals, "")
|
||||||
|
|
||||||
|
clusterTLSInfo = info.Cluster.TLSInfo
|
||||||
|
|
||||||
|
if !info.Cluster.RootRotationInProgress {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// root rotation not done
|
||||||
|
time.Sleep(250 * time.Millisecond)
|
||||||
|
}
|
||||||
|
c.Assert(clusterTLSInfo.TrustRoot, checker.Not(checker.Equals), currentTrustRoot)
|
||||||
|
if cert != nil {
|
||||||
|
c.Assert(clusterTLSInfo.TrustRoot, checker.Equals, expectedCert)
|
||||||
|
}
|
||||||
|
// could take another second or two for the nodes to trust the new roots after the've all gotten
|
||||||
|
// new TLS certificates
|
||||||
|
for j := 0; j < 18; j++ {
|
||||||
|
mInfo := m.GetNode(c, m.NodeID).Description.TLSInfo
|
||||||
|
wInfo := m.GetNode(c, w.NodeID).Description.TLSInfo
|
||||||
|
|
||||||
|
if mInfo.TrustRoot == clusterTLSInfo.TrustRoot && wInfo.TrustRoot == clusterTLSInfo.TrustRoot {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// nodes don't trust root certs yet
|
||||||
|
time.Sleep(250 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Assert(m.GetNode(c, m.NodeID).Description.TLSInfo, checker.DeepEquals, clusterTLSInfo)
|
||||||
|
c.Assert(m.GetNode(c, w.NodeID).Description.TLSInfo, checker.DeepEquals, clusterTLSInfo)
|
||||||
|
currentTrustRoot = clusterTLSInfo.TrustRoot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -37,6 +37,7 @@ type JSONProgress struct {
|
||||||
Start int64 `json:"start,omitempty"`
|
Start int64 `json:"start,omitempty"`
|
||||||
// If true, don't show xB/yB
|
// If true, don't show xB/yB
|
||||||
HideCounts bool `json:"hidecounts,omitempty"`
|
HideCounts bool `json:"hidecounts,omitempty"`
|
||||||
|
Units string `json:"units,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *JSONProgress) String() string {
|
func (p *JSONProgress) String() string {
|
||||||
|
@ -55,11 +56,16 @@ func (p *JSONProgress) String() string {
|
||||||
if p.Current <= 0 && p.Total <= 0 {
|
if p.Current <= 0 && p.Total <= 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
current := units.HumanSize(float64(p.Current))
|
|
||||||
if p.Total <= 0 {
|
if p.Total <= 0 {
|
||||||
|
switch p.Units {
|
||||||
|
case "":
|
||||||
|
current := units.HumanSize(float64(p.Current))
|
||||||
return fmt.Sprintf("%8v", current)
|
return fmt.Sprintf("%8v", current)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%d %s", p.Current, p.Units)
|
||||||
}
|
}
|
||||||
total := units.HumanSize(float64(p.Total))
|
}
|
||||||
|
|
||||||
percentage := int(float64(p.Current)/float64(p.Total)*100) / 2
|
percentage := int(float64(p.Current)/float64(p.Total)*100) / 2
|
||||||
if percentage > 50 {
|
if percentage > 50 {
|
||||||
percentage = 50
|
percentage = 50
|
||||||
|
@ -73,13 +79,25 @@ func (p *JSONProgress) String() string {
|
||||||
pbBox = fmt.Sprintf("[%s>%s] ", strings.Repeat("=", percentage), strings.Repeat(" ", numSpaces))
|
pbBox = fmt.Sprintf("[%s>%s] ", strings.Repeat("=", percentage), strings.Repeat(" ", numSpaces))
|
||||||
}
|
}
|
||||||
|
|
||||||
if !p.HideCounts {
|
switch {
|
||||||
|
case p.HideCounts:
|
||||||
|
case p.Units == "": // no units, use bytes
|
||||||
|
current := units.HumanSize(float64(p.Current))
|
||||||
|
total := units.HumanSize(float64(p.Total))
|
||||||
|
|
||||||
numbersBox = fmt.Sprintf("%8v/%v", current, total)
|
numbersBox = fmt.Sprintf("%8v/%v", current, total)
|
||||||
|
|
||||||
if p.Current > p.Total {
|
if p.Current > p.Total {
|
||||||
// remove total display if the reported current is wonky.
|
// remove total display if the reported current is wonky.
|
||||||
numbersBox = fmt.Sprintf("%8v", current)
|
numbersBox = fmt.Sprintf("%8v", current)
|
||||||
}
|
}
|
||||||
|
default:
|
||||||
|
numbersBox = fmt.Sprintf("%d/%d %s", p.Current, p.Total, p.Units)
|
||||||
|
|
||||||
|
if p.Current > p.Total {
|
||||||
|
// remove total display if the reported current is wonky.
|
||||||
|
numbersBox = fmt.Sprintf("%d %s", p.Current, p.Units)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if p.Current > 0 && p.Start > 0 && percentage < 50 {
|
if p.Current > 0 && p.Start > 0 && percentage < 50 {
|
||||||
|
|
|
@ -65,22 +65,50 @@ func TestProgress(t *testing.T) {
|
||||||
if jp5.String() != expected {
|
if jp5.String() != expected {
|
||||||
t.Fatalf("Expected %q, got %q", expected, jp5.String())
|
t.Fatalf("Expected %q, got %q", expected, jp5.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
expected = "[=========================> ] 50/100 units"
|
||||||
|
if termsz != nil && termsz.Width <= 110 {
|
||||||
|
expected = " 50/100 units"
|
||||||
|
}
|
||||||
|
jp6 := JSONProgress{Current: 50, Total: 100, Units: "units"}
|
||||||
|
if jp6.String() != expected {
|
||||||
|
t.Fatalf("Expected %q, got %q", expected, jp6.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// this number can't be negative
|
||||||
|
expected = "[==================================================>] 50 units"
|
||||||
|
if termsz != nil && termsz.Width <= 110 {
|
||||||
|
expected = " 50 units"
|
||||||
|
}
|
||||||
|
jp7 := JSONProgress{Current: 50, Total: 40, Units: "units"}
|
||||||
|
if jp7.String() != expected {
|
||||||
|
t.Fatalf("Expected %q, got %q", expected, jp7.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
expected = "[=========================> ] "
|
||||||
|
if termsz != nil && termsz.Width <= 110 {
|
||||||
|
expected = ""
|
||||||
|
}
|
||||||
|
jp8 := JSONProgress{Current: 50, Total: 100, HideCounts: true}
|
||||||
|
if jp8.String() != expected {
|
||||||
|
t.Fatalf("Expected %q, got %q", expected, jp8.String())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestJSONMessageDisplay(t *testing.T) {
|
func TestJSONMessageDisplay(t *testing.T) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
messages := map[JSONMessage][]string{
|
messages := map[JSONMessage][]string{
|
||||||
// Empty
|
// Empty
|
||||||
JSONMessage{}: {"\n", "\n"},
|
{}: {"\n", "\n"},
|
||||||
// Status
|
// Status
|
||||||
JSONMessage{
|
{
|
||||||
Status: "status",
|
Status: "status",
|
||||||
}: {
|
}: {
|
||||||
"status\n",
|
"status\n",
|
||||||
"status\n",
|
"status\n",
|
||||||
},
|
},
|
||||||
// General
|
// General
|
||||||
JSONMessage{
|
{
|
||||||
Time: now.Unix(),
|
Time: now.Unix(),
|
||||||
ID: "ID",
|
ID: "ID",
|
||||||
From: "From",
|
From: "From",
|
||||||
|
@ -90,7 +118,7 @@ func TestJSONMessageDisplay(t *testing.T) {
|
||||||
fmt.Sprintf("%v ID: (from From) status\n", time.Unix(now.Unix(), 0).Format(jsonlog.RFC3339NanoFixed)),
|
fmt.Sprintf("%v ID: (from From) status\n", time.Unix(now.Unix(), 0).Format(jsonlog.RFC3339NanoFixed)),
|
||||||
},
|
},
|
||||||
// General, with nano precision time
|
// General, with nano precision time
|
||||||
JSONMessage{
|
{
|
||||||
TimeNano: now.UnixNano(),
|
TimeNano: now.UnixNano(),
|
||||||
ID: "ID",
|
ID: "ID",
|
||||||
From: "From",
|
From: "From",
|
||||||
|
@ -100,7 +128,7 @@ func TestJSONMessageDisplay(t *testing.T) {
|
||||||
fmt.Sprintf("%v ID: (from From) status\n", time.Unix(0, now.UnixNano()).Format(jsonlog.RFC3339NanoFixed)),
|
fmt.Sprintf("%v ID: (from From) status\n", time.Unix(0, now.UnixNano()).Format(jsonlog.RFC3339NanoFixed)),
|
||||||
},
|
},
|
||||||
// General, with both times Nano is preferred
|
// General, with both times Nano is preferred
|
||||||
JSONMessage{
|
{
|
||||||
Time: now.Unix(),
|
Time: now.Unix(),
|
||||||
TimeNano: now.UnixNano(),
|
TimeNano: now.UnixNano(),
|
||||||
ID: "ID",
|
ID: "ID",
|
||||||
|
@ -111,7 +139,7 @@ func TestJSONMessageDisplay(t *testing.T) {
|
||||||
fmt.Sprintf("%v ID: (from From) status\n", time.Unix(0, now.UnixNano()).Format(jsonlog.RFC3339NanoFixed)),
|
fmt.Sprintf("%v ID: (from From) status\n", time.Unix(0, now.UnixNano()).Format(jsonlog.RFC3339NanoFixed)),
|
||||||
},
|
},
|
||||||
// Stream over status
|
// Stream over status
|
||||||
JSONMessage{
|
{
|
||||||
Status: "status",
|
Status: "status",
|
||||||
Stream: "stream",
|
Stream: "stream",
|
||||||
}: {
|
}: {
|
||||||
|
@ -119,7 +147,7 @@ func TestJSONMessageDisplay(t *testing.T) {
|
||||||
"stream",
|
"stream",
|
||||||
},
|
},
|
||||||
// With progress message
|
// With progress message
|
||||||
JSONMessage{
|
{
|
||||||
Status: "status",
|
Status: "status",
|
||||||
ProgressMessage: "progressMessage",
|
ProgressMessage: "progressMessage",
|
||||||
}: {
|
}: {
|
||||||
|
@ -127,7 +155,7 @@ func TestJSONMessageDisplay(t *testing.T) {
|
||||||
"status progressMessage",
|
"status progressMessage",
|
||||||
},
|
},
|
||||||
// With progress, stream empty
|
// With progress, stream empty
|
||||||
JSONMessage{
|
{
|
||||||
Status: "status",
|
Status: "status",
|
||||||
Stream: "",
|
Stream: "",
|
||||||
Progress: &JSONProgress{Current: 1},
|
Progress: &JSONProgress{Current: 1},
|
||||||
|
|
|
@ -18,6 +18,8 @@ type Progress struct {
|
||||||
|
|
||||||
// If true, don't show xB/yB
|
// If true, don't show xB/yB
|
||||||
HideCounts bool
|
HideCounts bool
|
||||||
|
// If not empty, use units instead of bytes for counts
|
||||||
|
Units string
|
||||||
|
|
||||||
// Aux contains extra information not presented to the user, such as
|
// Aux contains extra information not presented to the user, such as
|
||||||
// digests for push signing.
|
// digests for push signing.
|
||||||
|
|
|
@ -117,7 +117,7 @@ func (out *progressOutput) WriteProgress(prog progress.Progress) error {
|
||||||
if prog.Message != "" {
|
if prog.Message != "" {
|
||||||
formatted = out.sf.formatStatus(prog.ID, prog.Message)
|
formatted = out.sf.formatStatus(prog.ID, prog.Message)
|
||||||
} else {
|
} else {
|
||||||
jsonProgress := jsonmessage.JSONProgress{Current: prog.Current, Total: prog.Total, HideCounts: prog.HideCounts}
|
jsonProgress := jsonmessage.JSONProgress{Current: prog.Current, Total: prog.Total, HideCounts: prog.HideCounts, Units: prog.Units}
|
||||||
formatted = out.sf.formatProgress(prog.ID, prog.Action, &jsonProgress, prog.Aux)
|
formatted = out.sf.formatProgress(prog.ID, prog.Action, &jsonProgress, prog.Aux)
|
||||||
}
|
}
|
||||||
_, err := out.out.Write(formatted)
|
_, err := out.out.Write(formatted)
|
||||||
|
|
Loading…
Reference in New Issue