Skip to content

Commit aa81784

Browse files
Add ability to enroll with defined ID and replace_token (#6498) (#6807)
Allows an Elastic Agent to enroll with a defined ID and replacement token to allow it to replace an existing Elastic Agent. The original Elastic Agent must have also been enrolled with the same --replace-token or it will not be allow to enroll if the --id collides with an existing Elastic Agent. (cherry picked from commit 8a878fc) Co-authored-by: Blake Rouse <blake.rouse@elastic.co>
1 parent 98c3126 commit aa81784

File tree

12 files changed

+486
-23
lines changed

12 files changed

+486
-23
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Kind can be one of:
2+
# - breaking-change: a change to previously-documented behavior
3+
# - deprecation: functionality that is being removed in a later release
4+
# - bug-fix: fixes a problem in a previous version
5+
# - enhancement: extends functionality but does not break or fix existing behavior
6+
# - feature: new functionality
7+
# - known-issue: problems that we are aware of in a given version
8+
# - security: impacts on the security of a product or a user’s deployment.
9+
# - upgrade: important information for someone upgrading from a prior version
10+
# - other: does not fit into any of the other categories
11+
kind: feature
12+
13+
# Change summary; a 80ish characters long description of the change.
14+
summary: Add --id and --replace-token to enrollment
15+
16+
# Long description; in case the summary is not enough to describe the change
17+
# this field accommodate a description without length limits.
18+
# NOTE: This field will be rendered only for breaking-change and known-issue kinds at the moment.
19+
description: |
20+
Add support for --id and --replace-token to the install and enroll command. Add support for ELASTIC_AGENT_ID
21+
and FLEET_REPLACE_TOKEN to the container support the same behavior as the enroll command. Allows the ability to
22+
define a specific ID to use for the Elastic Agent when enrolling into Fleet. The replace-token defines the token
23+
that must be used to re-enroll an Elastic Agent with the same ID as a replacement of the previous Elastic Agent.
24+
25+
# Affected component; usually one of "elastic-agent", "fleet-server", "filebeat", "metricbeat", "auditbeat", "all", etc.
26+
component:
27+
28+
# PR URL; optional; the PR number that added the changeset.
29+
# If not present is automatically filled by the tooling finding the PR where this changelog fragment has been added.
30+
# NOTE: the tooling supports backports, so it's able to fill the original PR number instead of the backport PR number.
31+
# Please provide it if you are adding a fragment for a different PR.
32+
pr: https://github.com/elastic/elastic-agent/pull/6498
33+
34+
# Issue URL; optional; the GitHub issue related to this changeset (either closes or is part of).
35+
# If not present is automatically filled by the tooling with the issue linked to the PR number.
36+
issue: https://github.com/elastic/elastic-agent/issues/6361

internal/pkg/agent/application/coordinator/diagnostics_test.go

+4-2
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ func TestDiagnosticLocalConfig(t *testing.T) {
5959
Fleet: &configuration.FleetAgentConfig{
6060
Enabled: true,
6161
AccessAPIKey: "test-key",
62-
EnrollmentTokenHash: "test-hash",
62+
EnrollmentTokenHash: "test-enroll-hash",
63+
ReplaceTokenHash: "test-replace-hash",
6364
Client: remote.Config{
6465
Protocol: "test-protocol",
6566
},
@@ -120,7 +121,8 @@ agent:
120121
fleet:
121122
enabled: true
122123
access_api_key: "test-key"
123-
enrollment_token_hash: "test-hash"
124+
enrollment_token_hash: "test-enroll-hash"
125+
replace_token_hash: "test-replace-hash"
124126
agent:
125127
protocol: "test-protocol"
126128
`

internal/pkg/agent/cmd/container.go

+55-4
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,12 @@ func buildEnrollArgs(cfg setupConfig, token string, policyID string) ([]string,
507507
if token != "" {
508508
args = append(args, "--enrollment-token", token)
509509
}
510+
if cfg.Fleet.ID != "" {
511+
args = append(args, "--id", cfg.Fleet.ID)
512+
}
513+
if cfg.Fleet.ReplaceToken != "" {
514+
args = append(args, "--replace-token", cfg.Fleet.ReplaceToken)
515+
}
510516
if cfg.Fleet.DaemonTimeout != 0 {
511517
args = append(args, "--daemon-timeout")
512518
args = append(args, cfg.Fleet.DaemonTimeout.String())
@@ -1031,7 +1037,9 @@ func shouldFleetEnroll(setupCfg setupConfig) (bool, error) {
10311037
return true, nil
10321038
}
10331039

1034-
ctx := context.Background()
1040+
ctx, cancel := context.WithCancel(context.Background())
1041+
defer cancel()
1042+
10351043
store, err := newEncryptedDiskStore(ctx, agentCfgFilePath)
10361044
if err != nil {
10371045
return false, fmt.Errorf("failed to instantiate encrypted disk store: %w", err)
@@ -1052,6 +1060,13 @@ func shouldFleetEnroll(setupCfg setupConfig) (bool, error) {
10521060
return false, fmt.Errorf("failed to read from disk store: %w", err)
10531061
}
10541062

1063+
// Check if enrolling with a specifically defined Elastic Agent ID.
1064+
// If the ID's don't match then it needs to enroll.
1065+
if setupCfg.Fleet.ID != "" && (storedConfig.Fleet.Info == nil || storedConfig.Fleet.Info.ID != setupCfg.Fleet.ID) {
1066+
// ID is a mismatch
1067+
return true, nil
1068+
}
1069+
10551070
storedFleetHosts := storedConfig.Fleet.Client.GetHosts()
10561071
if len(storedFleetHosts) == 0 || !slices.Contains(storedFleetHosts, setupCfg.Fleet.URL) {
10571072
// The Fleet URL in the setup does not exist in the stored configuration, so enrollment is required.
@@ -1064,7 +1079,7 @@ func shouldFleetEnroll(setupCfg setupConfig) (bool, error) {
10641079
if len(storedConfig.Fleet.EnrollmentTokenHash) > 0 && len(setupCfg.Fleet.EnrollmentToken) > 0 {
10651080
enrollmentHashBytes, err := base64.StdEncoding.DecodeString(storedConfig.Fleet.EnrollmentTokenHash)
10661081
if err != nil {
1067-
return false, fmt.Errorf("failed to decode hash: %w", err)
1082+
return false, fmt.Errorf("failed to decode enrollment token hash: %w", err)
10681083
}
10691084

10701085
err = crypto.ComparePBKDF2HashAndPassword(enrollmentHashBytes, []byte(setupCfg.Fleet.EnrollmentToken))
@@ -1073,7 +1088,26 @@ func shouldFleetEnroll(setupCfg setupConfig) (bool, error) {
10731088
// The stored enrollment token hash does not match the new token, so enrollment is required.
10741089
return true, nil
10751090
case err != nil:
1076-
return false, fmt.Errorf("failed to compare hash: %w", err)
1091+
return false, fmt.Errorf("failed to compare enrollment token hash: %w", err)
1092+
}
1093+
}
1094+
1095+
// Evaluate the stored replace token hash against the setup replace token if both are present.
1096+
// Note that when "upgrading" from an older agent version the replace token hash will not exist
1097+
// in the stored configuration.
1098+
if len(storedConfig.Fleet.ReplaceTokenHash) > 0 && len(setupCfg.Fleet.ReplaceToken) > 0 {
1099+
replaceHashBytes, err := base64.StdEncoding.DecodeString(storedConfig.Fleet.ReplaceTokenHash)
1100+
if err != nil {
1101+
return false, fmt.Errorf("failed to decode replace token hash: %w", err)
1102+
}
1103+
1104+
err = crypto.ComparePBKDF2HashAndPassword(replaceHashBytes, []byte(setupCfg.Fleet.ReplaceToken))
1105+
switch {
1106+
case errors.Is(err, crypto.ErrMismatchedHashAndPassword):
1107+
// The stored enrollment token hash does not match the new token, so enrollment is required.
1108+
return true, nil
1109+
case err != nil:
1110+
return false, fmt.Errorf("failed to compare replace token hash: %w", err)
10771111
}
10781112
}
10791113

@@ -1100,16 +1134,33 @@ func shouldFleetEnroll(setupCfg setupConfig) (bool, error) {
11001134
return false, fmt.Errorf("failed to validate api token: %w", err)
11011135
}
11021136

1137+
saveConfig := false
1138+
11031139
// Update the stored enrollment token hash if there is no previous enrollment token hash
11041140
// (can happen when "upgrading" from an older version of the agent) and setup enrollment token is present.
11051141
if len(storedConfig.Fleet.EnrollmentTokenHash) == 0 && len(setupCfg.Fleet.EnrollmentToken) > 0 {
11061142
enrollmentHashBytes, err := crypto.GeneratePBKDF2FromPassword([]byte(setupCfg.Fleet.EnrollmentToken))
11071143
if err != nil {
1108-
return false, errors.New("failed to generate enrollment hash")
1144+
return false, errors.New("failed to generate enrollment token hash")
11091145
}
11101146
enrollmentTokenHash := base64.StdEncoding.EncodeToString(enrollmentHashBytes)
11111147
storedConfig.Fleet.EnrollmentTokenHash = enrollmentTokenHash
1148+
saveConfig = true
1149+
}
1150+
1151+
// Update the stored replace token hash if there is no previous replace token hash
1152+
// (can happen when "upgrading" from an older version of the agent) and setup replace token is present.
1153+
if len(storedConfig.Fleet.ReplaceTokenHash) == 0 && len(setupCfg.Fleet.ReplaceToken) > 0 {
1154+
replaceHashBytes, err := crypto.GeneratePBKDF2FromPassword([]byte(setupCfg.Fleet.ReplaceToken))
1155+
if err != nil {
1156+
return false, errors.New("failed to generate replace token hash")
1157+
}
1158+
replaceTokenHash := base64.StdEncoding.EncodeToString(replaceHashBytes)
1159+
storedConfig.Fleet.ReplaceTokenHash = replaceTokenHash
1160+
saveConfig = true
1161+
}
11121162

1163+
if saveConfig {
11131164
data, err := yaml.Marshal(storedConfig)
11141165
if err != nil {
11151166
return false, errors.New("could not marshal config")

internal/pkg/agent/cmd/container_test.go

+171-2
Original file line numberDiff line numberDiff line change
@@ -259,12 +259,19 @@ func TestKibanaFetchToken(t *testing.T) {
259259
}
260260

261261
func TestShouldEnroll(t *testing.T) {
262-
enrollmentToken := "test-token"
262+
// enroll token
263+
enrollmentToken := "test-enroll-token"
263264
enrollmentTokenHash, err := crypto.GeneratePBKDF2FromPassword([]byte(enrollmentToken))
264265
require.NoError(t, err)
265266
enrollmentTokenHashBase64 := base64.StdEncoding.EncodeToString(enrollmentTokenHash)
267+
enrollmentTokenOther := "test-enroll-token-other"
266268

267-
enrollmentTokenOther := "test-token-other"
269+
// replace token
270+
replaceToken := "test-replace-token"
271+
replaceTokenHash, err := crypto.GeneratePBKDF2FromPassword([]byte(replaceToken))
272+
require.NoError(t, err)
273+
replaceTokenHashBase64 := base64.StdEncoding.EncodeToString(replaceTokenHash)
274+
replaceTokenOther := "test-replace-token-other"
268275

269276
fleetNetworkErr := errors.New("fleet network error")
270277
for name, tc := range map[string]struct {
@@ -289,6 +296,41 @@ func TestShouldEnroll(t *testing.T) {
289296
cfg: setupConfig{Fleet: fleetConfig{Enroll: true, Force: true}},
290297
expectedShouldEnroll: true,
291298
},
299+
"should enroll on agent id but no existing id": {
300+
statFn: func(path string) (os.FileInfo, error) { return nil, nil },
301+
cfg: setupConfig{Fleet: fleetConfig{Enroll: true, URL: "host1", ID: "diff-agent-id"}},
302+
encryptedDiskStoreFn: func(t *testing.T, savedConfig *configuration.Configuration) storage.Storage {
303+
m := mockStorage.NewStorage(t)
304+
m.On("Load").Return(io.NopCloser(strings.NewReader(`fleet:
305+
enabled: true
306+
access_api_key: "test-key"
307+
enrollment_token_hash: "test-hash"
308+
hosts:
309+
- host1
310+
agent:
311+
protocol: "https"`)), nil).Once()
312+
return m
313+
},
314+
expectedShouldEnroll: true,
315+
},
316+
"should enroll on agent id but diff agent id": {
317+
statFn: func(path string) (os.FileInfo, error) { return nil, nil },
318+
cfg: setupConfig{Fleet: fleetConfig{Enroll: true, URL: "host1", ID: "diff-agent-id"}},
319+
encryptedDiskStoreFn: func(t *testing.T, savedConfig *configuration.Configuration) storage.Storage {
320+
m := mockStorage.NewStorage(t)
321+
m.On("Load").Return(io.NopCloser(strings.NewReader(`fleet:
322+
enabled: true
323+
access_api_key: "test-key"
324+
enrollment_token_hash: "test-hash"
325+
hosts:
326+
- host1
327+
agent:
328+
id: "agent-id"
329+
protocol: "https"`)), nil).Once()
330+
return m
331+
},
332+
expectedShouldEnroll: true,
333+
},
292334
"should enroll on fleet url change": {
293335
statFn: func(path string) (os.FileInfo, error) { return nil, nil },
294336
cfg: setupConfig{Fleet: fleetConfig{Enroll: true, URL: "host1"}},
@@ -321,6 +363,26 @@ func TestShouldEnroll(t *testing.T) {
321363
- host2
322364
- host3
323365
agent:
366+
protocol: "https"`)), nil).Once()
367+
return m
368+
},
369+
expectedShouldEnroll: true,
370+
},
371+
"should enroll on replace token change": {
372+
statFn: func(path string) (os.FileInfo, error) { return nil, nil },
373+
cfg: setupConfig{Fleet: fleetConfig{Enroll: true, URL: "host1", EnrollmentToken: enrollmentToken, ReplaceToken: replaceTokenOther}},
374+
encryptedDiskStoreFn: func(t *testing.T, savedConfig *configuration.Configuration) storage.Storage {
375+
m := mockStorage.NewStorage(t)
376+
m.On("Load").Return(io.NopCloser(strings.NewReader(`fleet:
377+
enabled: true
378+
access_api_key: "test-key"
379+
enrollment_token_hash: "`+enrollmentTokenHashBase64+`"
380+
replace_token_hash: "`+replaceTokenHashBase64+`"
381+
hosts:
382+
- host1
383+
- host2
384+
- host3
385+
agent:
324386
protocol: "https"`)), nil).Once()
325387
return m
326388
},
@@ -373,6 +435,44 @@ func TestShouldEnroll(t *testing.T) {
373435
- host2
374436
- host3
375437
agent:
438+
protocol: "https"`)), nil).Once()
439+
return m
440+
},
441+
fleetClientFn: func(t *testing.T) client.Sender {
442+
tries := 0
443+
m := mockFleetClient.NewSender(t)
444+
call := m.On("Send", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
445+
call.Run(func(args mock.Arguments) {
446+
if tries <= 1 {
447+
call.Return(nil, fleetNetworkErr)
448+
} else {
449+
call.Return(&http.Response{
450+
StatusCode: http.StatusOK,
451+
Body: io.NopCloser(strings.NewReader(`{"action": "acks", "items":[]}`)),
452+
}, nil)
453+
}
454+
tries++
455+
}).Times(3)
456+
return m
457+
},
458+
expectedShouldEnroll: false,
459+
},
460+
"should not enroll on no changes with agent ID and replace token": {
461+
statFn: func(path string) (os.FileInfo, error) { return nil, nil },
462+
cfg: setupConfig{Fleet: fleetConfig{Enroll: true, URL: "host1", ID: "custom-id", EnrollmentToken: enrollmentToken, ReplaceToken: replaceToken}},
463+
encryptedDiskStoreFn: func(t *testing.T, savedConfig *configuration.Configuration) storage.Storage {
464+
m := mockStorage.NewStorage(t)
465+
m.On("Load").Return(io.NopCloser(strings.NewReader(`fleet:
466+
enabled: true
467+
access_api_key: "test-key"
468+
enrollment_token_hash: "`+enrollmentTokenHashBase64+`"
469+
replace_token_hash: "`+replaceTokenHashBase64+`"
470+
hosts:
471+
- host1
472+
- host2
473+
- host3
474+
agent:
475+
id: "custom-id"
376476
protocol: "https"`)), nil).Once()
377477
return m
378478
},
@@ -433,6 +533,33 @@ func TestShouldEnroll(t *testing.T) {
433533
- host2
434534
- host3
435535
agent:
536+
protocol: "https"`)), nil).Once()
537+
return m
538+
},
539+
fleetClientFn: func(t *testing.T) client.Sender {
540+
m := mockFleetClient.NewSender(t)
541+
m.On("Send", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
542+
Return(&http.Response{
543+
StatusCode: http.StatusOK,
544+
Body: io.NopCloser(strings.NewReader(`{"action": "acks", "items":[]}`)),
545+
}, nil).Once()
546+
return m
547+
},
548+
expectedShouldEnroll: false,
549+
},
550+
"should not update the replace token hash if it does not exist in setup configuration": {
551+
statFn: func(path string) (os.FileInfo, error) { return nil, nil },
552+
cfg: setupConfig{Fleet: fleetConfig{Enroll: true, URL: "host1", EnrollmentToken: "", ReplaceToken: ""}},
553+
encryptedDiskStoreFn: func(t *testing.T, savedConfig *configuration.Configuration) storage.Storage {
554+
m := mockStorage.NewStorage(t)
555+
m.On("Load").Return(io.NopCloser(strings.NewReader(`fleet:
556+
enabled: true
557+
access_api_key: "test-key"
558+
hosts:
559+
- host1
560+
- host2
561+
- host3
562+
agent:
436563
protocol: "https"`)), nil).Once()
437564
return m
438565
},
@@ -486,6 +613,48 @@ func TestShouldEnroll(t *testing.T) {
486613
},
487614
expectedShouldEnroll: false,
488615
},
616+
"should not enroll on no changes and update the stored enrollment and replace token hash": {
617+
statFn: func(path string) (os.FileInfo, error) { return nil, nil },
618+
cfg: setupConfig{Fleet: fleetConfig{Enroll: true, URL: "host1", EnrollmentToken: enrollmentToken, ReplaceToken: replaceToken}},
619+
encryptedDiskStoreFn: func(t *testing.T, savedConfig *configuration.Configuration) storage.Storage {
620+
m := mockStorage.NewStorage(t)
621+
m.On("Load").Return(io.NopCloser(strings.NewReader(`fleet:
622+
enabled: true
623+
access_api_key: "test-key"
624+
hosts:
625+
- host1
626+
- host2
627+
- host3
628+
agent:
629+
protocol: "https"`)), nil).Once()
630+
m.On("Save", mock.Anything).Run(func(args mock.Arguments) {
631+
reader := args.Get(0).(io.Reader)
632+
data, _ := io.ReadAll(reader)
633+
_ = yaml.Unmarshal(data, savedConfig)
634+
}).Return(nil).Times(0)
635+
return m
636+
},
637+
fleetClientFn: func(t *testing.T) client.Sender {
638+
m := mockFleetClient.NewSender(t)
639+
m.On("Send", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
640+
Return(&http.Response{
641+
StatusCode: http.StatusOK,
642+
Body: io.NopCloser(strings.NewReader(`{"action": "acks", "items":[]}`)),
643+
}, nil).Once()
644+
return m
645+
},
646+
expectedSavedConfig: func(t *testing.T, savedConfig *configuration.Configuration) {
647+
require.NotNil(t, savedConfig)
648+
require.NotNil(t, savedConfig.Fleet)
649+
enrollmentTokenHash, err := base64.StdEncoding.DecodeString(savedConfig.Fleet.EnrollmentTokenHash)
650+
require.NoError(t, err)
651+
require.NoError(t, crypto.ComparePBKDF2HashAndPassword(enrollmentTokenHash, []byte(enrollmentToken)))
652+
replaceTokenHash, err := base64.StdEncoding.DecodeString(savedConfig.Fleet.ReplaceTokenHash)
653+
require.NoError(t, err)
654+
require.NoError(t, crypto.ComparePBKDF2HashAndPassword(replaceTokenHash, []byte(replaceToken)))
655+
},
656+
expectedShouldEnroll: false,
657+
},
489658
} {
490659
t.Run(name, func(t *testing.T) {
491660
savedConfig := &configuration.Configuration{}

0 commit comments

Comments
 (0)