Skip to content

Commit eb8abc9

Browse files
authored
Merge pull request moby#32993 from cyli/root-rotation-cli
API changes to rotate swarm root CA
2 parents c307f45 + 376c75d commit eb8abc9

File tree

9 files changed

+169
-15
lines changed

9 files changed

+169
-15
lines changed

api/swagger.yaml

+8
Original file line numberDiff line numberDiff line change
@@ -1886,6 +1886,14 @@ definitions:
18861886
CACert:
18871887
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)."
18881888
type: "string"
1889+
SigningCACert:
1890+
description: "The desired signing CA certificate for all swarm node TLS leaf certificates, in PEM format."
1891+
type: "string"
1892+
SigningCAKey:
1893+
description: "The desired signing CA key for all swarm node TLS leaf certificates, in PEM format."
1894+
type: "string"
1895+
ForceRotate:
1896+
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`"
18891897
EncryptionConfig:
18901898
description: "Parameters related to encryption-at-rest."
18911899
type: "object"

api/types/swarm/swarm.go

+10
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,16 @@ type CAConfig struct {
109109
// ExternalCAs is a list of CAs to which a manager node will make
110110
// certificate signing requests for node certificates.
111111
ExternalCAs []*ExternalCA `json:",omitempty"`
112+
113+
// SigningCACert and SigningCAKey specify the desired signing root CA and
114+
// root CA key for the swarm. When inspecting the cluster, the key will
115+
// be redacted.
116+
SigningCACert string `json:",omitempty"`
117+
SigningCAKey string `json:",omitempty"`
118+
119+
// If this value changes, and there is no specified signing cert and key,
120+
// then the swarm is forced to generate a new root certificate ane key.
121+
ForceRotate uint64 `json:",omitempty"`
112122
}
113123

114124
// ExternalCAProtocol represents type of external CA.

daemon/cluster/convert/swarm.go

+13
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ func SwarmFromGRPC(c swarmapi.Cluster) types.Swarm {
3030
EncryptionConfig: types.EncryptionConfig{
3131
AutoLockManagers: c.Spec.EncryptionConfig.AutoLockManagers,
3232
},
33+
CAConfig: types.CAConfig{
34+
// do not include the signing CA key (it should already be redacted via the swarm APIs)
35+
SigningCACert: string(c.Spec.CAConfig.SigningCACert),
36+
ForceRotate: c.Spec.CAConfig.ForceRotate,
37+
},
3338
},
3439
TLSInfo: types.TLSInfo{
3540
TrustRoot: string(c.RootCA.CACert),
@@ -114,6 +119,14 @@ func MergeSwarmSpecToGRPC(s types.Spec, spec swarmapi.ClusterSpec) (swarmapi.Clu
114119
if s.CAConfig.NodeCertExpiry != 0 {
115120
spec.CAConfig.NodeCertExpiry = gogotypes.DurationProto(s.CAConfig.NodeCertExpiry)
116121
}
122+
if s.CAConfig.SigningCACert != "" {
123+
spec.CAConfig.SigningCACert = []byte(s.CAConfig.SigningCACert)
124+
}
125+
if s.CAConfig.SigningCAKey != "" {
126+
// do propagate the signing CA key here because we want to provide it TO the swarm APIs
127+
spec.CAConfig.SigningCAKey = []byte(s.CAConfig.SigningCAKey)
128+
}
129+
spec.CAConfig.ForceRotate = s.CAConfig.ForceRotate
117130

118131
for _, ca := range s.CAConfig.ExternalCAs {
119132
protocol, ok := swarmapi.ExternalCA_CAProtocol_value[strings.ToUpper(string(ca.Protocol))]

docs/api/version-history.md

+4-1
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,14 @@ keywords: "API, Docker, rcli, REST, documentation"
1919

2020
* `GET /info` now returns the list of supported logging drivers, including plugins.
2121
* `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
22-
leaf certificate issuer's subject and public key.
22+
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.
2323
* `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.
2424
* `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
2525
issuer's subject and public key.
2626
* `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.
27+
* `POST /swarm/update` now accepts 3 additional parameters as part of the swarm spec's CA configuration; the desired CA certificate for
28+
the swarm, the desired CA key for the swarm (if not using an external certificate), and an optional parameter to force swarm to
29+
generate and rotate to a new CA certificate/key pair.
2730

2831
## v1.29 API changes
2932

integration-cli/docker_api_swarm_test.go

+72
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,15 @@ import (
1414
"sync"
1515
"time"
1616

17+
"github.com/cloudflare/cfssl/csr"
1718
"github.com/cloudflare/cfssl/helpers"
19+
"github.com/cloudflare/cfssl/initca"
1820
"github.com/docker/docker/api/types"
1921
"github.com/docker/docker/api/types/container"
2022
"github.com/docker/docker/api/types/swarm"
2123
"github.com/docker/docker/integration-cli/checker"
2224
"github.com/docker/docker/integration-cli/daemon"
25+
"github.com/docker/swarmkit/ca"
2326
"github.com/go-check/check"
2427
)
2528

@@ -930,3 +933,72 @@ func (s *DockerSwarmSuite) TestAPISwarmHealthcheckNone(c *check.C) {
930933
out, err = d.Cmd("exec", containers[0], "ping", "-c1", "-W3", "top")
931934
c.Assert(err, checker.IsNil, check.Commentf(out))
932935
}
936+
937+
func (s *DockerSwarmSuite) TestSwarmRepeatedRootRotation(c *check.C) {
938+
m := s.AddDaemon(c, true, true)
939+
w := s.AddDaemon(c, true, false)
940+
941+
info, err := m.SwarmInfo()
942+
c.Assert(err, checker.IsNil)
943+
944+
currentTrustRoot := info.Cluster.TLSInfo.TrustRoot
945+
946+
// rotate multiple times
947+
for i := 0; i < 4; i++ {
948+
var cert, key []byte
949+
if i%2 != 0 {
950+
cert, _, key, err = initca.New(&csr.CertificateRequest{
951+
CN: "newRoot",
952+
KeyRequest: csr.NewBasicKeyRequest(),
953+
CA: &csr.CAConfig{Expiry: ca.RootCAExpiration},
954+
})
955+
c.Assert(err, checker.IsNil)
956+
}
957+
expectedCert := string(cert)
958+
m.UpdateSwarm(c, func(s *swarm.Spec) {
959+
s.CAConfig.SigningCACert = expectedCert
960+
s.CAConfig.SigningCAKey = string(key)
961+
s.CAConfig.ForceRotate++
962+
})
963+
964+
// poll to make sure update succeeds
965+
var clusterTLSInfo swarm.TLSInfo
966+
for j := 0; j < 18; j++ {
967+
info, err := m.SwarmInfo()
968+
c.Assert(err, checker.IsNil)
969+
c.Assert(info.Cluster.Spec.CAConfig.SigningCACert, checker.Equals, expectedCert)
970+
// the desired CA key is always redacted
971+
c.Assert(info.Cluster.Spec.CAConfig.SigningCAKey, checker.Equals, "")
972+
973+
clusterTLSInfo = info.Cluster.TLSInfo
974+
975+
if !info.Cluster.RootRotationInProgress {
976+
break
977+
}
978+
979+
// root rotation not done
980+
time.Sleep(250 * time.Millisecond)
981+
}
982+
c.Assert(clusterTLSInfo.TrustRoot, checker.Not(checker.Equals), currentTrustRoot)
983+
if cert != nil {
984+
c.Assert(clusterTLSInfo.TrustRoot, checker.Equals, expectedCert)
985+
}
986+
// could take another second or two for the nodes to trust the new roots after the've all gotten
987+
// new TLS certificates
988+
for j := 0; j < 18; j++ {
989+
mInfo := m.GetNode(c, m.NodeID).Description.TLSInfo
990+
wInfo := m.GetNode(c, w.NodeID).Description.TLSInfo
991+
992+
if mInfo.TrustRoot == clusterTLSInfo.TrustRoot && wInfo.TrustRoot == clusterTLSInfo.TrustRoot {
993+
break
994+
}
995+
996+
// nodes don't trust root certs yet
997+
time.Sleep(250 * time.Millisecond)
998+
}
999+
1000+
c.Assert(m.GetNode(c, m.NodeID).Description.TLSInfo, checker.DeepEquals, clusterTLSInfo)
1001+
c.Assert(m.GetNode(c, w.NodeID).Description.TLSInfo, checker.DeepEquals, clusterTLSInfo)
1002+
currentTrustRoot = clusterTLSInfo.TrustRoot
1003+
}
1004+
}

pkg/jsonmessage/jsonmessage.go

+23-5
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ type JSONProgress struct {
3636
Total int64 `json:"total,omitempty"`
3737
Start int64 `json:"start,omitempty"`
3838
// If true, don't show xB/yB
39-
HideCounts bool `json:"hidecounts,omitempty"`
39+
HideCounts bool `json:"hidecounts,omitempty"`
40+
Units string `json:"units,omitempty"`
4041
}
4142

4243
func (p *JSONProgress) String() string {
@@ -55,11 +56,16 @@ func (p *JSONProgress) String() string {
5556
if p.Current <= 0 && p.Total <= 0 {
5657
return ""
5758
}
58-
current := units.HumanSize(float64(p.Current))
5959
if p.Total <= 0 {
60-
return fmt.Sprintf("%8v", current)
60+
switch p.Units {
61+
case "":
62+
current := units.HumanSize(float64(p.Current))
63+
return fmt.Sprintf("%8v", current)
64+
default:
65+
return fmt.Sprintf("%d %s", p.Current, p.Units)
66+
}
6167
}
62-
total := units.HumanSize(float64(p.Total))
68+
6369
percentage := int(float64(p.Current)/float64(p.Total)*100) / 2
6470
if percentage > 50 {
6571
percentage = 50
@@ -73,13 +79,25 @@ func (p *JSONProgress) String() string {
7379
pbBox = fmt.Sprintf("[%s>%s] ", strings.Repeat("=", percentage), strings.Repeat(" ", numSpaces))
7480
}
7581

76-
if !p.HideCounts {
82+
switch {
83+
case p.HideCounts:
84+
case p.Units == "": // no units, use bytes
85+
current := units.HumanSize(float64(p.Current))
86+
total := units.HumanSize(float64(p.Total))
87+
7788
numbersBox = fmt.Sprintf("%8v/%v", current, total)
7889

7990
if p.Current > p.Total {
8091
// remove total display if the reported current is wonky.
8192
numbersBox = fmt.Sprintf("%8v", current)
8293
}
94+
default:
95+
numbersBox = fmt.Sprintf("%d/%d %s", p.Current, p.Total, p.Units)
96+
97+
if p.Current > p.Total {
98+
// remove total display if the reported current is wonky.
99+
numbersBox = fmt.Sprintf("%d %s", p.Current, p.Units)
100+
}
83101
}
84102

85103
if p.Current > 0 && p.Start > 0 && percentage < 50 {

pkg/jsonmessage/jsonmessage_test.go

+36-8
Original file line numberDiff line numberDiff line change
@@ -65,22 +65,50 @@ func TestProgress(t *testing.T) {
6565
if jp5.String() != expected {
6666
t.Fatalf("Expected %q, got %q", expected, jp5.String())
6767
}
68+
69+
expected = "[=========================> ] 50/100 units"
70+
if termsz != nil && termsz.Width <= 110 {
71+
expected = " 50/100 units"
72+
}
73+
jp6 := JSONProgress{Current: 50, Total: 100, Units: "units"}
74+
if jp6.String() != expected {
75+
t.Fatalf("Expected %q, got %q", expected, jp6.String())
76+
}
77+
78+
// this number can't be negative
79+
expected = "[==================================================>] 50 units"
80+
if termsz != nil && termsz.Width <= 110 {
81+
expected = " 50 units"
82+
}
83+
jp7 := JSONProgress{Current: 50, Total: 40, Units: "units"}
84+
if jp7.String() != expected {
85+
t.Fatalf("Expected %q, got %q", expected, jp7.String())
86+
}
87+
88+
expected = "[=========================> ] "
89+
if termsz != nil && termsz.Width <= 110 {
90+
expected = ""
91+
}
92+
jp8 := JSONProgress{Current: 50, Total: 100, HideCounts: true}
93+
if jp8.String() != expected {
94+
t.Fatalf("Expected %q, got %q", expected, jp8.String())
95+
}
6896
}
6997

7098
func TestJSONMessageDisplay(t *testing.T) {
7199
now := time.Now()
72100
messages := map[JSONMessage][]string{
73101
// Empty
74-
JSONMessage{}: {"\n", "\n"},
102+
{}: {"\n", "\n"},
75103
// Status
76-
JSONMessage{
104+
{
77105
Status: "status",
78106
}: {
79107
"status\n",
80108
"status\n",
81109
},
82110
// General
83-
JSONMessage{
111+
{
84112
Time: now.Unix(),
85113
ID: "ID",
86114
From: "From",
@@ -90,7 +118,7 @@ func TestJSONMessageDisplay(t *testing.T) {
90118
fmt.Sprintf("%v ID: (from From) status\n", time.Unix(now.Unix(), 0).Format(jsonlog.RFC3339NanoFixed)),
91119
},
92120
// General, with nano precision time
93-
JSONMessage{
121+
{
94122
TimeNano: now.UnixNano(),
95123
ID: "ID",
96124
From: "From",
@@ -100,7 +128,7 @@ func TestJSONMessageDisplay(t *testing.T) {
100128
fmt.Sprintf("%v ID: (from From) status\n", time.Unix(0, now.UnixNano()).Format(jsonlog.RFC3339NanoFixed)),
101129
},
102130
// General, with both times Nano is preferred
103-
JSONMessage{
131+
{
104132
Time: now.Unix(),
105133
TimeNano: now.UnixNano(),
106134
ID: "ID",
@@ -111,23 +139,23 @@ func TestJSONMessageDisplay(t *testing.T) {
111139
fmt.Sprintf("%v ID: (from From) status\n", time.Unix(0, now.UnixNano()).Format(jsonlog.RFC3339NanoFixed)),
112140
},
113141
// Stream over status
114-
JSONMessage{
142+
{
115143
Status: "status",
116144
Stream: "stream",
117145
}: {
118146
"stream",
119147
"stream",
120148
},
121149
// With progress message
122-
JSONMessage{
150+
{
123151
Status: "status",
124152
ProgressMessage: "progressMessage",
125153
}: {
126154
"status progressMessage",
127155
"status progressMessage",
128156
},
129157
// With progress, stream empty
130-
JSONMessage{
158+
{
131159
Status: "status",
132160
Stream: "",
133161
Progress: &JSONProgress{Current: 1},

pkg/progress/progress.go

+2
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ type Progress struct {
1818

1919
// If true, don't show xB/yB
2020
HideCounts bool
21+
// If not empty, use units instead of bytes for counts
22+
Units string
2123

2224
// Aux contains extra information not presented to the user, such as
2325
// digests for push signing.

pkg/streamformatter/streamformatter.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ func (out *progressOutput) WriteProgress(prog progress.Progress) error {
117117
if prog.Message != "" {
118118
formatted = out.sf.formatStatus(prog.ID, prog.Message)
119119
} else {
120-
jsonProgress := jsonmessage.JSONProgress{Current: prog.Current, Total: prog.Total, HideCounts: prog.HideCounts}
120+
jsonProgress := jsonmessage.JSONProgress{Current: prog.Current, Total: prog.Total, HideCounts: prog.HideCounts, Units: prog.Units}
121121
formatted = out.sf.formatProgress(prog.ID, prog.Action, &jsonProgress, prog.Aux)
122122
}
123123
_, err := out.out.Write(formatted)

0 commit comments

Comments
 (0)