diff --git a/NOTICE.txt b/NOTICE.txt index d45af706d..6ffea6189 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -5033,6 +5033,43 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +-------------------------------------------------------------------------------- +Dependency : golang.org/x/crypto +Version: v0.26.0 +Licence type (autodetected): BSD-3-Clause +-------------------------------------------------------------------------------- + +Contents of probable licence file $GOMODCACHE/golang.org/x/crypto@v0.26.0/LICENSE: + +Copyright 2009 The Go Authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google LLC nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + -------------------------------------------------------------------------------- Dependency : golang.org/x/sync Version: v0.8.0 @@ -20047,43 +20084,6 @@ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. --------------------------------------------------------------------------------- -Dependency : golang.org/x/crypto -Version: v0.26.0 -Licence type (autodetected): BSD-3-Clause --------------------------------------------------------------------------------- - -Contents of probable licence file $GOMODCACHE/golang.org/x/crypto@v0.26.0/LICENSE: - -Copyright 2009 The Go Authors. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above -copyright notice, this list of conditions and the following disclaimer -in the documentation and/or other materials provided with the -distribution. - * Neither the name of Google LLC nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - - -------------------------------------------------------------------------------- Dependency : golang.org/x/mod Version: v0.20.0 diff --git a/changelog/fragments/1736300562-Add-ability-for-enrollment-to-take-an-agent-ID.yaml b/changelog/fragments/1736300562-Add-ability-for-enrollment-to-take-an-agent-ID.yaml new file mode 100644 index 000000000..eb1b5a698 --- /dev/null +++ b/changelog/fragments/1736300562-Add-ability-for-enrollment-to-take-an-agent-ID.yaml @@ -0,0 +1,32 @@ +# Kind can be one of: +# - breaking-change: a change to previously-documented behavior +# - deprecation: functionality that is being removed in a later release +# - bug-fix: fixes a problem in a previous version +# - enhancement: extends functionality but does not break or fix existing behavior +# - feature: new functionality +# - known-issue: problems that we are aware of in a given version +# - security: impacts on the security of a product or a user’s deployment. +# - upgrade: important information for someone upgrading from a prior version +# - other: does not fit into any of the other categories +kind: feature + +# Change summary; a 80ish characters long description of the change. +summary: Add ability for enrollment to take an agent ID + +# Long description; in case the summary is not enough to describe the change +# this field accommodate a description without length limits. +# NOTE: This field will be rendered only for breaking-change and known-issue kinds at the moment. +#description: + +# Affected component; usually one of "elastic-agent", "fleet-server", "filebeat", "metricbeat", "auditbeat", "all", etc. +component: fleet-server + +# PR URL; optional; the PR number that added the changeset. +# If not present is automatically filled by the tooling finding the PR where this changelog fragment has been added. +# NOTE: the tooling supports backports, so it's able to fill the original PR number instead of the backport PR number. +# Please provide it if you are adding a fragment for a different PR. +pr: https://github.com/elastic/fleet-server/pull/4290 + +# Issue URL; optional; the GitHub issue related to this changeset (either closes or is part of). +# If not present is automatically filled by the tooling with the issue linked to the PR number. +issue: https://github.com/elastic/fleet-server/issues/4226 diff --git a/go.mod b/go.mod index 36a955d7f..e5cd1a7d1 100644 --- a/go.mod +++ b/go.mod @@ -35,6 +35,7 @@ require ( go.elastic.co/apm/v2 v2.6.2 go.elastic.co/ecszerolog v0.2.0 go.uber.org/zap v1.27.0 + golang.org/x/crypto v0.26.0 golang.org/x/sync v0.8.0 golang.org/x/time v0.5.0 google.golang.org/grpc v1.63.2 @@ -89,7 +90,6 @@ require ( go.opentelemetry.io/otel/metric v1.28.0 // indirect go.opentelemetry.io/otel/trace v1.28.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.26.0 // indirect golang.org/x/mod v0.20.0 // indirect golang.org/x/net v0.28.0 // indirect golang.org/x/sys v0.25.0 // indirect diff --git a/internal/pkg/api/error.go b/internal/pkg/api/error.go index 6c60f93ee..c70a67e87 100644 --- a/internal/pkg/api/error.go +++ b/internal/pkg/api/error.go @@ -100,6 +100,24 @@ func NewHTTPErrResp(err error) HTTPErrResp { zerolog.InfoLevel, }, }, + { + ErrAgentReplaceTokenInvalid, + HTTPErrResp{ + http.StatusBadRequest, + "AgentReplaceTokenInvalid", + "replace token is invalid", + zerolog.InfoLevel, + }, + }, + { + ErrAgentNotReplaceable, + HTTPErrResp{ + http.StatusForbidden, + "AgentNotReplaceable", + "existing agent cannot be replaced", + zerolog.WarnLevel, + }, + }, { ErrInvalidUserAgent, HTTPErrResp{ diff --git a/internal/pkg/api/handleEnroll.go b/internal/pkg/api/handleEnroll.go index 1090e7f24..c7189e814 100644 --- a/internal/pkg/api/handleEnroll.go +++ b/internal/pkg/api/handleEnroll.go @@ -31,6 +31,7 @@ import ( "github.com/hashicorp/go-version" "github.com/miolini/datacounter" "github.com/rs/zerolog" + "golang.org/x/crypto/bcrypt" ) const ( @@ -55,9 +56,11 @@ const kFleetAccessRolesJSON = ` ` var ( - ErrUnknownEnrollType = errors.New("unknown enroll request type") - ErrInactiveEnrollmentKey = errors.New("inactive enrollment key") - ErrPolicyNotFound = errors.New("policy not found") + ErrUnknownEnrollType = errors.New("unknown enroll request type") + ErrInactiveEnrollmentKey = errors.New("inactive enrollment key") + ErrPolicyNotFound = errors.New("policy not found") + ErrAgentReplaceTokenInvalid = errors.New("replace token is invalid") + ErrAgentNotReplaceable = errors.New("existing agent cannot be replaced") ) type EnrollerT struct { @@ -197,10 +200,24 @@ func (et *EnrollerT) _enroll( ) (*EnrollResponse, error) { var agent model.Agent var enrollmentID string + var replaceToken string span, ctx := apm.StartSpan(ctx, "enroll", "process") defer span.End() + now := time.Now() + + if req.ReplaceToken != nil && *req.ReplaceToken != "" { + replaceTokenBytes, err := bcrypt.GenerateFromPassword([]byte(*req.ReplaceToken), bcrypt.DefaultCost) + if err != nil { + zlog.Debug().Err(err). + Msg("Enroll request had an invalid replace token") + // not a valid replace token + return nil, ErrAgentReplaceTokenInvalid + } + replaceToken = string(replaceTokenBytes) + } + if req.EnrollmentId != nil { vSpan, vCtx := apm.StartSpan(ctx, "checkEnrollmentID", "validate") enrollmentID = *req.EnrollmentId @@ -217,15 +234,7 @@ func (et *EnrollerT) _enroll( } vSpan.End() } - now := time.Now() - - // Generate an ID here so we can pre-create the api key and avoid a round trip - u, err := uuid.NewV4() - if err != nil { - return nil, err - } - agentID := u.String() // only delete existing agent if it never checked in if agent.Id != "" && agent.LastCheckin == "" { zlog.Debug(). @@ -252,6 +261,81 @@ func (et *EnrollerT) _enroll( Msg("Error when trying to delete old agent with enrollment id") return nil, err } + // deleted, so clear the ID so code below knows it needs to be created + agent.Id = "" + } + + var agentID string + if req.Id != nil && *req.Id != "" { + agentID = *req.Id + + // check if the agent with this ID already exists + var err error + agent, err = et._checkAgent(ctx, zlog, agentID) + if err != nil { + return nil, err + } + + if agent.Id != "" { + // confirm that this agent has a set replace token + // one is required or replacement of this already enrolled and active + // agent is not allowed + if agent.ReplaceToken == "" { + zlog.Warn(). + Str("AgentId", agent.Id). + Msg("Existing agent with same ID already enrolled without a replace token set") + return nil, ErrAgentNotReplaceable + } + if req.ReplaceToken == nil || *req.ReplaceToken == "" { + zlog.Warn(). + Str("AgentId", agent.Id). + Msg("Existing agent with same ID already enrolled; no replace token given during enrollment") + return nil, ErrAgentNotReplaceable + } + err = bcrypt.CompareHashAndPassword([]byte(agent.ReplaceToken), []byte(*req.ReplaceToken)) + if err != nil { + // not the same, cannot replace + // provides no real reason as that would expose too much information + zlog.Debug().Err(err). + Str("AgentId", agent.Id). + Msg("Existing agent with same ID already enrolled; replace token didn't match") + return nil, ErrAgentNotReplaceable + } + + // confirm that its on the same policy + // it is not supported to have it the same ID enroll into different policies + if agent.PolicyID != policyID { + zlog.Warn(). + Str("AgentId", agent.Id). + Str("PolicyId", policyID). + Str("CurrentPolicyId", agent.PolicyID). + Msg("Existing agent with same ID already enrolled into another policy") + return nil, ErrAgentNotReplaceable + } + + // invalidate the previous api key + // this has to be done because it's not possible to get the previous token + // so the other is invalidated and a new one is generated + zlog.Debug(). + Str("AgentId", agent.Id). + Str("APIKeyID", agent.AccessAPIKeyID). + Msg("Invalidate old api key with same id") + err := invalidateAPIKey(ctx, zlog, et.bulker, agent.AccessAPIKeyID) + if err != nil { + zlog.Error().Err(err). + Str("AgentId", agent.Id). + Str("APIKeyID", agent.AccessAPIKeyID). + Msg("Error when trying to invalidate API key of old agent with same id") + return nil, err + } + } + } else { + // No ID provided so generate an ID. + u, err := uuid.NewV4() + if err != nil { + return nil, err + } + agentID = u.String() } // Update the local metadata agent id @@ -271,47 +355,84 @@ func (et *EnrollerT) _enroll( return invalidateAPIKey(ctx, zlog, et.bulker, accessAPIKey.ID) }) - agentData := model.Agent{ - Active: true, - PolicyID: policyID, - Namespaces: namespaces, - Type: string(req.Type), - EnrolledAt: now.UTC().Format(time.RFC3339), - LocalMetadata: localMeta, - AccessAPIKeyID: accessAPIKey.ID, - ActionSeqNo: []int64{sqn.UndefinedSeqNo}, - Agent: &model.AgentMetadata{ + // Existing agent, only update a subset of the fields + if agent.Id != "" { + agent.Active = true + agent.Namespaces = namespaces + agent.LocalMetadata = localMeta + agent.AccessAPIKeyID = accessAPIKey.ID + agent.Agent = &model.AgentMetadata{ ID: agentID, Version: ver, - }, - Tags: removeDuplicateStr(req.Metadata.Tags), - EnrollmentID: enrollmentID, - } + } + agent.Tags = removeDuplicateStr(req.Metadata.Tags) + agentField, err := json.Marshal(agent.Agent) + if err != nil { + return nil, fmt.Errorf("failed to marshal agent to JSON: %w", err) + } + // update the agent record + // clears state of policy revision, as this agent needs to get the latest policy + // clears state of unenrollment, as this is a new enrollment + doc := bulk.UpdateFields{ + dl.FieldNamespaces: namespaces, + dl.FieldLocalMetadata: json.RawMessage(localMeta), + dl.FieldAccessAPIKeyID: accessAPIKey.ID, + dl.FieldAgent: json.RawMessage(agentField), + dl.FieldTags: agent.Tags, + dl.FieldPolicyRevisionIdx: 0, + dl.FieldAuditUnenrolledTime: nil, + dl.FieldAuditUnenrolledReason: nil, + dl.FieldUnenrolledAt: nil, + dl.FieldUnenrolledReason: nil, + dl.FieldUpdatedAt: now.UTC().Format(time.RFC3339), + } + err = updateFleetAgent(ctx, et.bulker, agentID, doc) + if err != nil { + return nil, err + } + } else { + agent = model.Agent{ + Active: true, + PolicyID: policyID, + Namespaces: namespaces, + Type: string(req.Type), + EnrolledAt: now.UTC().Format(time.RFC3339), + LocalMetadata: localMeta, + AccessAPIKeyID: accessAPIKey.ID, + ActionSeqNo: []int64{sqn.UndefinedSeqNo}, + Agent: &model.AgentMetadata{ + ID: agentID, + Version: ver, + }, + Tags: removeDuplicateStr(req.Metadata.Tags), + EnrollmentID: enrollmentID, + ReplaceToken: replaceToken, + } - err = createFleetAgent(ctx, et.bulker, agentID, agentData) - if err != nil { - return nil, err + err = createFleetAgent(ctx, et.bulker, agentID, agent) + if err != nil { + return nil, err + } + // Register delete fleet agent for enrollment error rollback + rb.Register("delete agent", func(ctx context.Context) error { + return deleteAgent(ctx, zlog, et.bulker, agentID) + }) } - // Register delete fleet agent for enrollment error rollback - rb.Register("delete agent", func(ctx context.Context) error { - return deleteAgent(ctx, zlog, et.bulker, agentID) - }) - resp := EnrollResponse{ Action: "created", Item: EnrollResponseItem{ AccessApiKey: accessAPIKey.Token(), - AccessApiKeyId: agentData.AccessAPIKeyID, - Active: agentData.Active, - EnrolledAt: agentData.EnrolledAt, + AccessApiKeyId: agent.AccessAPIKeyID, + Active: agent.Active, + EnrolledAt: agent.EnrolledAt, Id: agentID, - LocalMetadata: agentData.LocalMetadata, - PolicyId: agentData.PolicyID, + LocalMetadata: agent.LocalMetadata, + PolicyId: agent.PolicyID, Status: "online", - Tags: agentData.Tags, - Type: agentData.Type, - UserProvidedMetadata: agentData.UserProvidedMetadata, + Tags: agent.Tags, + Type: agent.Type, + UserProvidedMetadata: agent.UserProvidedMetadata, }, } @@ -321,6 +442,41 @@ func (et *EnrollerT) _enroll( return &resp, nil } +func (et *EnrollerT) _checkAgent(ctx context.Context, zlog zerolog.Logger, agentID string) (model.Agent, error) { + vSpan, vCtx := apm.StartSpan(ctx, "checkAgentID", "validate") + defer vSpan.End() + + agent, err := dl.FindAgent(vCtx, et.bulker, dl.QueryAgentByID, dl.FieldID, agentID) + if err != nil { + zlog.Debug().Err(err). + Str("ID", agentID). + Msg("Agent with ID not found") + if !errors.Is(err, dl.ErrNotFound) && !strings.Contains(err.Error(), "no such index") { + return model.Agent{}, err + } + return model.Agent{}, nil + } else if !agent.Active { + // inactive agent has been unenrolled and the API key has already been invalidated + // delete the current record as the new enrollment will overwrite this one + zlog.Debug(). + Str("ID", agentID). + Msg("Inactive agent with ID found") + err = deleteAgent(ctx, zlog, et.bulker, agent.Id) + if err != nil { + zlog.Error().Err(err). + Str("AgentId", agent.Id). + Msg("Error when trying to delete old agent with same id") + return model.Agent{}, err + } + // deleted, so return like one is not found + return model.Agent{}, nil + } + zlog.Debug(). + Str("ID", agentID). + Msg("Active agent with ID found") + return agent, nil +} + // Helper function to remove duplicate agent tags. // Note that this implementation will also sort the tags alphabetically. func removeDuplicateStr(strSlice []string) []string { @@ -470,6 +626,17 @@ func updateLocalMetaAgentID(data []byte, agentID string) ([]byte, error) { return data, nil } +func updateFleetAgent(ctx context.Context, bulker bulk.Bulk, id string, doc bulk.UpdateFields) error { + span, ctx := apm.StartSpan(ctx, "updateAgent", "update") + defer span.End() + + body, err := doc.Marshal() + if err != nil { + return err + } + return bulker.Update(ctx, dl.FleetAgents, id, body, bulk.WithRefresh(), bulk.WithRetryOnConflict(3)) +} + func createFleetAgent(ctx context.Context, bulker bulk.Bulk, id string, agent model.Agent) error { span, ctx := apm.StartSpan(ctx, "createAgent", "create") defer span.End() diff --git a/internal/pkg/api/handleEnroll_test.go b/internal/pkg/api/handleEnroll_test.go index 7094ecdfe..36d465d85 100644 --- a/internal/pkg/api/handleEnroll_test.go +++ b/internal/pkg/api/handleEnroll_test.go @@ -9,6 +9,8 @@ package api import ( "context" "encoding/json" + "errors" + "fmt" "reflect" "strings" "testing" @@ -17,6 +19,7 @@ import ( "github.com/elastic/fleet-server/v7/internal/pkg/bulk" "github.com/elastic/fleet-server/v7/internal/pkg/cache" "github.com/elastic/fleet-server/v7/internal/pkg/config" + "github.com/elastic/fleet-server/v7/internal/pkg/dl" "github.com/elastic/fleet-server/v7/internal/pkg/es" "github.com/elastic/fleet-server/v7/internal/pkg/model" "github.com/elastic/fleet-server/v7/internal/pkg/rollback" @@ -24,6 +27,7 @@ import ( "github.com/rs/zerolog" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" + "golang.org/x/crypto/bcrypt" ) func TestRemoveDuplicateStr(t *testing.T) { @@ -97,6 +101,310 @@ func TestEnroll(t *testing.T) { } } +func TestEnrollWithAgentID(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + rb := &rollback.Rollback{} + zlog := zerolog.Logger{} + agentID := "1234" + req := &EnrollRequest{ + Type: "PERMANENT", + Id: &agentID, + Metadata: EnrollMetadata{ + UserProvided: []byte("{}"), + Local: []byte("{}"), + }, + } + verCon := mustBuildConstraints("8.9.0") + cfg := &config.Server{} + c, _ := cache.New(config.Cache{NumCounters: 100, MaxCost: 100000}) + bulker := ftesting.NewMockBulk() + et, _ := NewEnrollerT(verCon, cfg, bulker, c) + + bulker.On("Search", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&es.ResultT{ + HitsT: es.HitsT{ + Hits: make([]es.HitT, 0), + }, + }, nil) + bulker.On("APIKeyCreate", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return( + &apikey.APIKey{ + ID: "1234", + Key: "1234", + }, nil) + bulker.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return( + "", nil) + resp, _ := et._enroll(ctx, rb, zlog, req, "1234", []string{}, "8.9.0") + + if resp.Action != "created" { + t.Fatal("enroll failed") + } + if resp.Item.Id != agentID { + t.Fatalf("agent ID should have been %s (not %s)", agentID, resp.Item.Id) + } +} + +func TestEnrollWithAgentIDExistingNonActive(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + rb := &rollback.Rollback{} + zlog := zerolog.Logger{} + agentID := "1234" + req := &EnrollRequest{ + Type: "PERMANENT", + Id: &agentID, + Metadata: EnrollMetadata{ + UserProvided: []byte("{}"), + Local: []byte("{}"), + }, + } + verCon := mustBuildConstraints("8.9.0") + cfg := &config.Server{} + c, _ := cache.New(config.Cache{NumCounters: 100, MaxCost: 100000}) + bulker := ftesting.NewMockBulk() + et, _ := NewEnrollerT(verCon, cfg, bulker, c) + + bulker.On("Search", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&es.ResultT{ + HitsT: es.HitsT{ + Hits: []es.HitT{{ + ID: "1234", + Index: dl.FleetAgents, + Source: []byte(`{"active":false,"agent":{"id":"1234","version":"8.9.0"},"type":"PERMANENT","policy_id":"1234"}`), + }}, + }, + }, nil) + bulker.On("Delete", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) + bulker.On("APIKeyCreate", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return( + &apikey.APIKey{ + ID: "1234", + Key: "1234", + }, nil) + bulker.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return( + "", nil) + resp, _ := et._enroll(ctx, rb, zlog, req, "1234", []string{}, "8.9.0") + + if resp.Action != "created" { + t.Fatal("enroll failed") + } + if resp.Item.Id != agentID { + t.Fatalf("agent ID should have been %s (not %s)", agentID, resp.Item.Id) + } +} + +func TestEnrollWithAgentIDExistingActive_NotReplaceable(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + rb := &rollback.Rollback{} + zlog := zerolog.Logger{} + agentID := "1234" + req := &EnrollRequest{ + Type: "PERMANENT", + Id: &agentID, + Metadata: EnrollMetadata{ + UserProvided: []byte("{}"), + Local: []byte("{}"), + }, + } + verCon := mustBuildConstraints("8.9.0") + cfg := &config.Server{} + c, _ := cache.New(config.Cache{NumCounters: 100, MaxCost: 100000}) + bulker := ftesting.NewMockBulk() + et, _ := NewEnrollerT(verCon, cfg, bulker, c) + + bulker.On("Search", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&es.ResultT{ + HitsT: es.HitsT{ + Hits: []es.HitT{{ + ID: "1234", + Index: dl.FleetAgents, + Source: []byte(`{"active":true,"agent":{"id":"1234","version":"8.9.0"},"type":"PERMANENT","policy_id":"1234"}`), + }}, + }, + }, nil) + _, err := et._enroll(ctx, rb, zlog, req, "1234", []string{}, "8.9.0") + if !errors.Is(err, ErrAgentNotReplaceable) { + t.Fatal("should have got error ErrAgentNotReplaceable") + } +} + +func TestEnrollWithAgentIDExistingActive_InvalidReplaceToken_Missing(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + rb := &rollback.Rollback{} + zlog := zerolog.Logger{} + agentID := "1234" + replaceToken, err := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost) + if err != nil { + t.Fatalf("error generating bcrypt hash: %v", err) + } + req := &EnrollRequest{ + Type: "PERMANENT", + Id: &agentID, + Metadata: EnrollMetadata{ + UserProvided: []byte("{}"), + Local: []byte("{}"), + }, + } + verCon := mustBuildConstraints("8.9.0") + cfg := &config.Server{} + c, _ := cache.New(config.Cache{NumCounters: 100, MaxCost: 100000}) + bulker := ftesting.NewMockBulk() + et, _ := NewEnrollerT(verCon, cfg, bulker, c) + + source := fmt.Sprintf(`{"active":true,"agent":{"id":"1234","version":"8.9.0"},"type":"PERMANENT","policy_id":"1234","replace_token":"%s"}`, string(replaceToken)) + bulker.On("Search", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&es.ResultT{ + HitsT: es.HitsT{ + Hits: []es.HitT{{ + ID: "1234", + Index: dl.FleetAgents, + Source: []byte(source), + }}, + }, + }, nil) + _, err = et._enroll(ctx, rb, zlog, req, "1234", []string{}, "8.9.0") + if !errors.Is(err, ErrAgentNotReplaceable) { + t.Fatal("should have got error ErrAgentNotReplaceable") + } +} + +func TestEnrollWithAgentIDExistingActive_InvalidReplaceToken_Mismatch(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + rb := &rollback.Rollback{} + zlog := zerolog.Logger{} + agentID := "1234" + replaceToken, err := bcrypt.GenerateFromPassword([]byte("replace_token"), bcrypt.DefaultCost) + if err != nil { + t.Fatalf("error generating bcrypt hash: %v", err) + } + wrongToken := "wrong_token" + req := &EnrollRequest{ + Type: "PERMANENT", + Id: &agentID, + Metadata: EnrollMetadata{ + UserProvided: []byte("{}"), + Local: []byte("{}"), + }, + ReplaceToken: &wrongToken, + } + verCon := mustBuildConstraints("8.9.0") + cfg := &config.Server{} + c, _ := cache.New(config.Cache{NumCounters: 100, MaxCost: 100000}) + bulker := ftesting.NewMockBulk() + et, _ := NewEnrollerT(verCon, cfg, bulker, c) + + source := fmt.Sprintf(`{"active":true,"agent":{"id":"1234","version":"8.9.0"},"type":"PERMANENT","policy_id":"1234","replace_token":"%s"}`, string(replaceToken)) + bulker.On("Search", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&es.ResultT{ + HitsT: es.HitsT{ + Hits: []es.HitT{{ + ID: "1234", + Index: dl.FleetAgents, + Source: []byte(source), + }}, + }, + }, nil) + _, err = et._enroll(ctx, rb, zlog, req, "1234", []string{}, "8.9.0") + if !errors.Is(err, ErrAgentNotReplaceable) { + t.Fatal("should have got error ErrAgentNotReplaceable") + } +} + +func TestEnrollWithAgentIDExistingActive_WrongPolicy(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + rb := &rollback.Rollback{} + zlog := zerolog.Logger{} + agentID := "1234" + replaceToken := "replace_token" + replaceTokenHash, err := bcrypt.GenerateFromPassword([]byte(replaceToken), bcrypt.DefaultCost) + if err != nil { + t.Fatalf("error generating bcrypt hash: %v", err) + } + req := &EnrollRequest{ + Type: "PERMANENT", + Id: &agentID, + Metadata: EnrollMetadata{ + UserProvided: []byte("{}"), + Local: []byte("{}"), + }, + ReplaceToken: &replaceToken, + } + verCon := mustBuildConstraints("8.9.0") + cfg := &config.Server{} + c, _ := cache.New(config.Cache{NumCounters: 100, MaxCost: 100000}) + bulker := ftesting.NewMockBulk() + et, _ := NewEnrollerT(verCon, cfg, bulker, c) + + source := fmt.Sprintf(`{"active":true,"agent":{"id":"1234","version":"8.9.0"},"type":"PERMANENT","policy_id":"1234","replace_token":"%s"}`, string(replaceTokenHash)) + bulker.On("Search", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&es.ResultT{ + HitsT: es.HitsT{ + Hits: []es.HitT{{ + ID: "1234", + Index: dl.FleetAgents, + Source: []byte(source), + }}, + }, + }, nil) + _, err = et._enroll(ctx, rb, zlog, req, "5678", []string{}, "8.9.0") + if !errors.Is(err, ErrAgentNotReplaceable) { + t.Fatal("should have got error ErrAgentNotReplaceable") + } +} + +func TestEnrollWithAgentIDExistingActive(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + rb := &rollback.Rollback{} + zlog := zerolog.Logger{} + agentID := "1234" + replaceToken := "replace_token" + replaceTokenHash, err := bcrypt.GenerateFromPassword([]byte(replaceToken), bcrypt.DefaultCost) + if err != nil { + t.Fatalf("error generating bcrypt hash: %v", err) + } + req := &EnrollRequest{ + Type: "PERMANENT", + Id: &agentID, + Metadata: EnrollMetadata{ + UserProvided: []byte("{}"), + Local: []byte("{}"), + }, + ReplaceToken: &replaceToken, + } + verCon := mustBuildConstraints("8.9.0") + cfg := &config.Server{} + c, _ := cache.New(config.Cache{NumCounters: 100, MaxCost: 100000}) + bulker := ftesting.NewMockBulk() + et, _ := NewEnrollerT(verCon, cfg, bulker, c) + + source := fmt.Sprintf(`{"active":true,"agent":{"id":"1234","version":"8.9.0"},"type":"PERMANENT","policy_id":"1234","replace_token":"%s"}`, string(replaceTokenHash)) + bulker.On("Search", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&es.ResultT{ + HitsT: es.HitsT{ + Hits: []es.HitT{{ + ID: "1234", + Index: dl.FleetAgents, + Source: []byte(source), + }}, + }, + }, nil) + bulker.On("APIKeyRead", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return( + &apikey.APIKeyMetadata{ID: "1234"}, nil) + bulker.On("APIKeyInvalidate", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) + bulker.On("APIKeyCreate", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return( + &apikey.APIKey{ + ID: "1234", + Key: "1234", + }, nil) + bulker.On("Update", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return( + nil) + resp, _ := et._enroll(ctx, rb, zlog, req, "1234", []string{}, "8.9.0") + + if resp.Action != "created" { + t.Fatal("enroll failed") + } + if resp.Item.Id != agentID { + t.Fatalf("agent ID should have been %s (not %s)", agentID, resp.Item.Id) + } +} + func TestEnrollerT_retrieveStaticTokenEnrollmentToken(t *testing.T) { bulkerBuilder := func(policies ...model.Policy) func() bulk.Bulk { return func() bulk.Bulk { diff --git a/internal/pkg/api/openapi.gen.go b/internal/pkg/api/openapi.gen.go index 0e4bb10d0..e4dd4a80e 100644 --- a/internal/pkg/api/openapi.gen.go +++ b/internal/pkg/api/openapi.gen.go @@ -399,9 +399,21 @@ type EnrollRequest struct { // The existing agent with a matching enrollment_id will be deleted if it never checked in. The new agent will be enrolled with the enrollment_id. EnrollmentId *string `json:"enrollment_id,omitempty"` + // Id The ID of the agent. + // This is the ID that will be used to reference this agent, if no ID is passed one will be generated. + // If another agent is enrolled with the same ID the other agent will no longer be able to communicate, + // this new agent is considered a replacement of the other agent. The other agent will be able to continue + // sending data to ES. + Id *string `json:"id,omitempty"` + // Metadata Metadata associated with the agent that is enrolling to fleet. Metadata EnrollMetadata `json:"metadata"` + // ReplaceToken The replacement token of the agent. + // Provided when an agent could replace an existing agent. This token must match the original enrollment of + // that agent otherwise it will not be able to enroll. + ReplaceToken *string `json:"replace_token,omitempty"` + // SharedId The shared ID of the agent. // To support pre-existing installs. // diff --git a/internal/pkg/dl/constants.go b/internal/pkg/dl/constants.go index 4bd3be876..73d382789 100644 --- a/internal/pkg/dl/constants.go +++ b/internal/pkg/dl/constants.go @@ -46,6 +46,8 @@ const ( FieldUnhealthyReason = "unhealthy_reason" FieldActive = "active" + FieldNamespaces = "namespaces" + FieldTags = "tags" FieldUpdatedAt = "updated_at" FieldUnenrolledAt = "unenrolled_at" FieldUpgradedAt = "upgraded_at" diff --git a/internal/pkg/model/schema.go b/internal/pkg/model/schema.go index c5eaef2af..7991bd3aa 100644 --- a/internal/pkg/model/schema.go +++ b/internal/pkg/model/schema.go @@ -189,6 +189,9 @@ type Agent struct { // The current policy revision_idx for the Elastic Agent PolicyRevisionIdx int64 `json:"policy_revision_idx,omitempty"` + // hash of token provided during enrollment that allows replacement by another enrollment with same ID + ReplaceToken string `json:"replace_token,omitempty"` + // Shared ID SharedID string `json:"shared_id,omitempty"` diff --git a/internal/pkg/server/fleet_integration_test.go b/internal/pkg/server/fleet_integration_test.go index 4e474e596..6458cf2cd 100644 --- a/internal/pkg/server/fleet_integration_test.go +++ b/internal/pkg/server/fleet_integration_test.go @@ -707,7 +707,7 @@ func Test_SmokeTest_Agent_Calls(t *testing.T) { require.Falsef(t, ok, "expected response to have no errors attribute, errors are present: %+v", ackObj) } -func EnrollAgent(t *testing.T, ctx context.Context, srv *tserver, enrollBody string) (string, string) { +func EnrollAgent(t *testing.T, ctx context.Context, srv *tserver, enrollBody string) api.EnrollResponse { req, err := http.NewRequestWithContext(ctx, "POST", srv.baseURL()+"/api/fleet/agents/enroll", strings.NewReader(enrollBody)) require.NoError(t, err) req.Header.Set("Authorization", "ApiKey "+srv.enrollKey) @@ -717,29 +717,17 @@ func EnrollAgent(t *testing.T, ctx context.Context, srv *tserver, enrollBody str cli := cleanhttp.DefaultClient() res, err := cli.Do(req) require.NoError(t, err) + require.Equal(t, http.StatusOK, res.StatusCode) p, _ := io.ReadAll(res.Body) res.Body.Close() - var obj map[string]interface{} - err = json.Unmarshal(p, &obj) + var response api.EnrollResponse + err = json.Unmarshal(p, &response) require.NoError(t, err) - t.Log(obj) - - require.Equal(t, http.StatusOK, res.StatusCode) - - item := obj["item"] - mm, ok := item.(map[string]interface{}) - require.True(t, ok, "expected attribute item to be an object") - agentID := mm["id"] - - apiKey, ok := mm["access_api_key"] - require.True(t, ok, "expected attribute access_api_key is missing") - key, ok := apiKey.(string) - require.True(t, ok, "expected attribute access_api_key to be a string") - require.NotEmpty(t, key) + t.Log(response) - return agentID.(string), key + return response } func Test_Agent_Enrollment_Id(t *testing.T) { @@ -762,25 +750,25 @@ func Test_Agent_Enrollment_Id(t *testing.T) { ctx = testlog.SetLogger(t).WithContext(ctx) t.Log("Enroll the first agent with enrollment_id") - firstAgentID, _ := EnrollAgent(t, ctx, srv, enrollBodyWEnrollmentID) + firstEnroll := EnrollAgent(t, ctx, srv, enrollBodyWEnrollmentID) t.Log("Enroll the second agent with the same enrollment_id") - secondAgentID, _ := EnrollAgent(t, ctx, srv, enrollBodyWEnrollmentID) + secondEnroll := EnrollAgent(t, ctx, srv, enrollBodyWEnrollmentID) // cleanup defer func() { - err := srv.bulker.Delete(ctx, dl.FleetAgents, secondAgentID) + err := srv.bulker.Delete(ctx, dl.FleetAgents, firstEnroll.Item.Id) if err != nil { t.Log("could not clean up second agent") } - err2 := srv.bulker.Delete(ctx, dl.FleetAgents, firstAgentID) + err2 := srv.bulker.Delete(ctx, dl.FleetAgents, secondEnroll.Item.Id) if err2 != nil { t.Log("could not clean up first agent") } }() // checking that old agent with enrollment id is deleted - agent, err := dl.FindAgent(ctx, srv.bulker, dl.QueryAgentByID, dl.FieldID, firstAgentID) + agent, err := dl.FindAgent(ctx, srv.bulker, dl.QueryAgentByID, dl.FieldID, firstEnroll.Item.Id) t.Log(agent) if err != nil { t.Log("old agent not found as expected") @@ -809,9 +797,9 @@ func Test_Agent_Enrollment_Id_Invalidated_API_key(t *testing.T) { ctx = testlog.SetLogger(t).WithContext(ctx) t.Log("Enroll the first agent with enrollment_id") - firstAgentID, _ := EnrollAgent(t, ctx, srv, enrollBodyWEnrollmentID) + firstEnroll := EnrollAgent(t, ctx, srv, enrollBodyWEnrollmentID) - agent, err := dl.FindAgent(ctx, srv.bulker, dl.QueryAgentByID, dl.FieldID, firstAgentID) + agent, err := dl.FindAgent(ctx, srv.bulker, dl.QueryAgentByID, dl.FieldID, firstEnroll.Item.Id) if err != nil { t.Log("first agent not found") } @@ -824,22 +812,22 @@ func Test_Agent_Enrollment_Id_Invalidated_API_key(t *testing.T) { } t.Log("Enroll the second agent with the same enrollment_id") - secondAgentID, _ := EnrollAgent(t, ctx, srv, enrollBodyWEnrollmentID) + secondEnroll := EnrollAgent(t, ctx, srv, enrollBodyWEnrollmentID) // cleanup defer func() { - err := srv.bulker.Delete(ctx, dl.FleetAgents, secondAgentID) + err := srv.bulker.Delete(ctx, dl.FleetAgents, secondEnroll.Item.Id) if err != nil { t.Log("could not clean up second agent") } - err2 := srv.bulker.Delete(ctx, dl.FleetAgents, firstAgentID) + err2 := srv.bulker.Delete(ctx, dl.FleetAgents, firstEnroll.Item.Id) if err2 != nil { t.Log("could not clean up first agent") } }() // checking that old agent with enrollment id is deleted - agent, err = dl.FindAgent(ctx, srv.bulker, dl.QueryAgentByID, dl.FieldID, firstAgentID) + agent, err = dl.FindAgent(ctx, srv.bulker, dl.QueryAgentByID, dl.FieldID, firstEnroll.Item.Id) t.Log(agent) if err != nil { t.Log("old agent not found as expected") @@ -848,6 +836,161 @@ func Test_Agent_Enrollment_Id_Invalidated_API_key(t *testing.T) { } } +func Test_Agent_Id_No_ReplaceToken(t *testing.T) { + enrollBodyWID := `{ + "type": "PERMANENT", + "id": "123456", + "metadata": { + "user_provided": {}, + "local": {}, + "tags": [] + } + }` + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Start test server + srv, err := startTestServer(t, ctx, policyData) + require.NoError(t, err) + ctx = testlog.SetLogger(t).WithContext(ctx) + + t.Log("Enroll the first agent with id") + firstEnroll := EnrollAgent(t, ctx, srv, enrollBodyWID) + + // cleanup + defer func() { + err := srv.bulker.Delete(ctx, dl.FleetAgents, firstEnroll.Item.Id) + if err != nil { + t.Log("could not clean up agent") + } + }() + + t.Log("Enroll the second agent with the same id") + req, err := http.NewRequestWithContext(ctx, "POST", srv.baseURL()+"/api/fleet/agents/enroll", strings.NewReader(enrollBodyWID)) + require.NoError(t, err) + req.Header.Set("Authorization", "ApiKey "+srv.enrollKey) + req.Header.Set("User-Agent", "elastic agent "+serverVersion) + req.Header.Set("Content-Type", "application/json") + + cli := cleanhttp.DefaultClient() + res, err := cli.Do(req) + require.NoError(t, err) + _ = res.Body.Close() + require.Equal(t, http.StatusForbidden, res.StatusCode) +} + +func Test_Agent_Id_ReplaceToken_Mismatch(t *testing.T) { + enrollBodyWID := `{ + "type": "PERMANENT", + "id": "123456", + "replace_token": "replaceable", + "metadata": { + "user_provided": {}, + "local": {}, + "tags": [] + } + }` + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Start test server + srv, err := startTestServer(t, ctx, policyData) + require.NoError(t, err) + ctx = testlog.SetLogger(t).WithContext(ctx) + + t.Log("Enroll the first agent with id") + firstEnroll := EnrollAgent(t, ctx, srv, enrollBodyWID) + + // cleanup + defer func() { + err := srv.bulker.Delete(ctx, dl.FleetAgents, firstEnroll.Item.Id) + if err != nil { + t.Log("could not clean up agent") + } + }() + + t.Log("Enroll the second agent with the same id") + //nolint:gosec // disable G101 + enrollBodyBadReplaceToken := `{ + "type": "PERMANENT", + "id": "123456", + "replace_token": "replaceable_wrong", + "metadata": { + "user_provided": {}, + "local": {}, + "tags": [] + } + }` + req, err := http.NewRequestWithContext(ctx, "POST", srv.baseURL()+"/api/fleet/agents/enroll", strings.NewReader(enrollBodyBadReplaceToken)) + require.NoError(t, err) + req.Header.Set("Authorization", "ApiKey "+srv.enrollKey) + req.Header.Set("User-Agent", "elastic agent "+serverVersion) + req.Header.Set("Content-Type", "application/json") + + cli := cleanhttp.DefaultClient() + res, err := cli.Do(req) + require.NoError(t, err) + _ = res.Body.Close() + require.Equal(t, http.StatusForbidden, res.StatusCode) +} + +func Test_Agent_Id(t *testing.T) { + enrollBodyWID := `{ + "type": "PERMANENT", + "id": "123456", + "replace_token": "replaceable", + "metadata": { + "user_provided": {}, + "local": {}, + "tags": [] + } + }` + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Start test server + srv, err := startTestServer(t, ctx, policyData) + require.NoError(t, err) + ctx = testlog.SetLogger(t).WithContext(ctx) + + t.Log("Enroll the first agent with id") + firstEnroll := EnrollAgent(t, ctx, srv, enrollBodyWID) + + t.Log("Enroll the second agent with the same id") + secondEnroll := EnrollAgent(t, ctx, srv, enrollBodyWID) + + // cleanup + defer func() { + err := srv.bulker.Delete(ctx, dl.FleetAgents, firstEnroll.Item.Id) + if err != nil { + t.Log("could not clean up agent") + } + }() + + // check that the id's are expected values + if firstEnroll.Item.Id != "123456" { + t.Fatal("agent id is not expect value") + } + if firstEnroll.Item.Id != secondEnroll.Item.Id { + t.Fatal("agent id does not match") + } + + // check that the access key id's don't match + if firstEnroll.Item.AccessApiKeyId == secondEnroll.Item.AccessApiKeyId { + t.Fatal("agent access key id's should not match") + } + + // checking that updated agent has the access key ID from the second agent + agent, err := dl.FindAgent(ctx, srv.bulker, dl.QueryAgentByID, dl.FieldID, firstEnroll.Item.Id) + if err != nil { + t.Fatalf("could not find agent with id %s: %s", firstEnroll.Item.Id, err) + } + t.Log(agent) + if agent.AccessAPIKeyID != secondEnroll.Item.AccessApiKeyId { + t.Fatal("saved agent access key ID should be for the second enroll call") + } +} + func Test_Agent_Auth_errors(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -1322,13 +1465,13 @@ func Test_SmokeTest_Verify_v85Migrate(t *testing.T) { } }` t.Log("Enroll an agent") - id, key := EnrollAgent(t, ctx, srv, enrollBody) + resp := EnrollAgent(t, ctx, srv, enrollBody) // checkin - t.Logf("Fake a checkin for agent %s", id) - req, err := http.NewRequestWithContext(ctx, "POST", srv.baseURL()+"/api/fleet/agents/"+id+"/checkin", strings.NewReader(checkinBody)) + t.Logf("Fake a checkin for agent %s", resp.Item.Id) + req, err := http.NewRequestWithContext(ctx, "POST", srv.baseURL()+"/api/fleet/agents/"+resp.Item.Id+"/checkin", strings.NewReader(checkinBody)) require.NoError(t, err) - req.Header.Set("Authorization", "ApiKey "+key) + req.Header.Set("Authorization", "ApiKey "+resp.Item.AccessApiKey) req.Header.Set("User-Agent", "elastic agent "+serverVersion) req.Header.Set("Content-Type", "application/json") res, err := cli.Do(req) @@ -1362,7 +1505,7 @@ func Test_SmokeTest_Verify_v85Migrate(t *testing.T) { require.True(t, ok, "expected action agent_id attribute missing") aAgentID, ok := aAgentIDRaw.(string) require.True(t, ok, "expected action agent_id to be string") - require.Equal(t, id, aAgentID) + require.Equal(t, resp.Item.Id, aAgentID) body := fmt.Sprintf(`{ "events": [{ @@ -1372,11 +1515,11 @@ func Test_SmokeTest_Verify_v85Migrate(t *testing.T) { "type": "ACTION_RESULT", "subtype": "ACKNOWLEDGED" }] - }`, aID, id) - t.Logf("Fake an ack for action %s for agent %s", aID, id) - req, err = http.NewRequestWithContext(ctx, "POST", srv.baseURL()+"/api/fleet/agents/"+id+"/acks", strings.NewReader(body)) + }`, aID, resp.Item.Id) + t.Logf("Fake an ack for action %s for agent %s", aID, resp.Item.Id) + req, err = http.NewRequestWithContext(ctx, "POST", srv.baseURL()+"/api/fleet/agents/"+resp.Item.Id+"/acks", strings.NewReader(body)) require.NoError(t, err) - req.Header.Set("Authorization", "ApiKey "+key) + req.Header.Set("Authorization", "ApiKey "+resp.Item.AccessApiKey) req.Header.Set("Content-Type", "application/json") res, err = cli.Do(req) require.NoError(t, err) @@ -1395,7 +1538,7 @@ func Test_SmokeTest_Verify_v85Migrate(t *testing.T) { require.Falsef(t, ok, "expected response to have no errors attribute, errors are present: %+v", ackObj) // Update agent doc to have output key == "" - agent, err := dl.FindAgent(ctx, srv.bulker, dl.QueryAgentByID, dl.FieldID, id) + agent, err := dl.FindAgent(ctx, srv.bulker, dl.QueryAgentByID, dl.FieldID, resp.Item.Id) require.NoError(t, err) outputNames := make([]string, 0, len(agent.Outputs)) for name := range agent.Outputs { @@ -1403,11 +1546,11 @@ func Test_SmokeTest_Verify_v85Migrate(t *testing.T) { } require.Len(t, outputNames, 1) p = []byte(fmt.Sprintf(`{"script":{"lang": "painless", "source": "ctx._source['outputs'][params.output].api_key = ''; ctx._source['outputs'][params.output].api_key_id = '';", "params": {"output": "%s"}}}`, outputNames[0])) - t.Logf("Attempting to remove api_key attribute from: %s, body: %s", id, string(p)) + t.Logf("Attempting to remove api_key attribute from: %s, body: %s", resp.Item.Id, string(p)) err = srv.bulker.Update( ctx, dl.FleetAgents, - id, + resp.Item.Id, p, bulk.WithRefresh(), bulk.WithRetryOnConflict(3), @@ -1415,9 +1558,9 @@ func Test_SmokeTest_Verify_v85Migrate(t *testing.T) { require.NoError(t, err) // Checkin again to get policy change action and new keys - req, err = http.NewRequestWithContext(ctx, "POST", srv.baseURL()+"/api/fleet/agents/"+id+"/checkin", strings.NewReader(checkinBody)) + req, err = http.NewRequestWithContext(ctx, "POST", srv.baseURL()+"/api/fleet/agents/"+resp.Item.Id+"/checkin", strings.NewReader(checkinBody)) require.NoError(t, err) - req.Header.Set("Authorization", "ApiKey "+key) + req.Header.Set("Authorization", "ApiKey "+resp.Item.AccessApiKey) req.Header.Set("User-Agent", "elastic agent "+serverVersion) req.Header.Set("Content-Type", "application/json") res, err = cli.Do(req) @@ -1466,9 +1609,9 @@ func Test_SmokeTest_AuditUnenroll(t *testing.T) { } }` t.Log("Enroll an agent") - id, key := EnrollAgent(t, ctx, srv, enrollBody) + resp := EnrollAgent(t, ctx, srv, enrollBody) - t.Logf("Use audit/unenroll endpoint for agent %s", id) + t.Logf("Use audit/unenroll endpoint for agent %s", resp.Item.Id) orphanBody := `{ "reason": "orphaned", "timestamp": "2024-01-01T12:00:00.000Z" @@ -1477,9 +1620,9 @@ func Test_SmokeTest_AuditUnenroll(t *testing.T) { "reason": "uninstall", "timestamp": "2024-01-01T12:00:00.000Z" }` - req, err := http.NewRequestWithContext(ctx, "POST", srv.baseURL()+"/api/fleet/agents/"+id+"/audit/unenroll", strings.NewReader(uninstallBody)) + req, err := http.NewRequestWithContext(ctx, "POST", srv.baseURL()+"/api/fleet/agents/"+resp.Item.Id+"/audit/unenroll", strings.NewReader(uninstallBody)) require.NoError(t, err) - req.Header.Set("Authorization", "ApiKey "+key) + req.Header.Set("Authorization", "ApiKey "+resp.Item.AccessApiKey) req.Header.Set("User-Agent", "elastic agent "+serverVersion) req.Header.Set("Content-Type", "application/json") res, err := cli.Do(req) @@ -1488,9 +1631,9 @@ func Test_SmokeTest_AuditUnenroll(t *testing.T) { res.Body.Close() t.Log("Orphaned can replace uninstall") - req, err = http.NewRequestWithContext(ctx, "POST", srv.baseURL()+"/api/fleet/agents/"+id+"/audit/unenroll", strings.NewReader(orphanBody)) + req, err = http.NewRequestWithContext(ctx, "POST", srv.baseURL()+"/api/fleet/agents/"+resp.Item.Id+"/audit/unenroll", strings.NewReader(orphanBody)) require.NoError(t, err) - req.Header.Set("Authorization", "ApiKey "+key) + req.Header.Set("Authorization", "ApiKey "+resp.Item.AccessApiKey) req.Header.Set("User-Agent", "elastic agent "+serverVersion) req.Header.Set("Content-Type", "application/json") res, err = cli.Do(req) @@ -1499,9 +1642,9 @@ func Test_SmokeTest_AuditUnenroll(t *testing.T) { res.Body.Close() t.Log("Use of audit/unenroll once orphaned should fail.") - req, err = http.NewRequestWithContext(ctx, "POST", srv.baseURL()+"/api/fleet/agents/"+id+"/audit/unenroll", strings.NewReader(orphanBody)) + req, err = http.NewRequestWithContext(ctx, "POST", srv.baseURL()+"/api/fleet/agents/"+resp.Item.Id+"/audit/unenroll", strings.NewReader(orphanBody)) require.NoError(t, err) - req.Header.Set("Authorization", "ApiKey "+key) + req.Header.Set("Authorization", "ApiKey "+resp.Item.AccessApiKey) req.Header.Set("User-Agent", "elastic agent "+serverVersion) req.Header.Set("Content-Type", "application/json") res, err = cli.Do(req) @@ -1509,10 +1652,10 @@ func Test_SmokeTest_AuditUnenroll(t *testing.T) { require.Equal(t, http.StatusConflict, res.StatusCode) res.Body.Close() - t.Logf("Fake a checkin for agent %s", id) - req, err = http.NewRequestWithContext(ctx, "POST", srv.baseURL()+"/api/fleet/agents/"+id+"/checkin", strings.NewReader(checkinBody)) + t.Logf("Fake a checkin for agent %s", resp.Item.Id) + req, err = http.NewRequestWithContext(ctx, "POST", srv.baseURL()+"/api/fleet/agents/"+resp.Item.Id+"/checkin", strings.NewReader(checkinBody)) require.NoError(t, err) - req.Header.Set("Authorization", "ApiKey "+key) + req.Header.Set("Authorization", "ApiKey "+resp.Item.AccessApiKey) req.Header.Set("User-Agent", "elastic agent "+serverVersion) req.Header.Set("Content-Type", "application/json") res, err = cli.Do(req) @@ -1527,7 +1670,7 @@ func Test_SmokeTest_AuditUnenroll(t *testing.T) { require.NoError(t, err) require.Eventuallyf(t, func() bool { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost:9200/.fleet-agents/_doc/"+id, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost:9200/.fleet-agents/_doc/"+resp.Item.Id, nil) require.NoError(t, err) req.SetBasicAuth("elastic", "changeme") res, err := cli.Do(req) diff --git a/internal/pkg/server/namespaces_integration_test.go b/internal/pkg/server/namespaces_integration_test.go index 4ad77c8ce..819af8292 100644 --- a/internal/pkg/server/namespaces_integration_test.go +++ b/internal/pkg/server/namespaces_integration_test.go @@ -221,24 +221,24 @@ func Test_Agent_Namespace_test1(t *testing.T) { t.Log("Enroll agent") srvCopy := srv srvCopy.enrollKey = newKey.Token() - agentID, key := EnrollAgent(t, ctx, srvCopy, enrollBody) + resp := EnrollAgent(t, ctx, srvCopy, enrollBody) - AssertAgentDocContainNamespace(t, ctx, srv, agentID, testNamespace) + AssertAgentDocContainNamespace(t, ctx, srv, resp.Item.Id, testNamespace) // cleanup defer func() { - err = srv.bulker.Delete(ctx, dl.FleetAgents, agentID) + err = srv.bulker.Delete(ctx, dl.FleetAgents, resp.Item.Id) if err != nil { t.Log("could not clean up agent") } }() - actionID := AgentCheckin(t, ctx, srvCopy, agentID, key) - AgentAck(t, ctx, srvCopy, actionID, agentID, key) + actionID := AgentCheckin(t, ctx, srvCopy, resp.Item.Id, resp.Item.AccessApiKey) + AgentAck(t, ctx, srvCopy, actionID, resp.Item.Id, resp.Item.AccessApiKey) t.Log("Create SETTINGS Action") newActionID, _ := uuid.NewV4() var actionData = model.Action{ - Agents: []string{agentID}, + Agents: []string{resp.Item.Id}, Expiration: time.Now().Add(time.Hour * 2000).Format(time.RFC3339), ActionID: newActionID.String(), Namespaces: []string{"test1"}, @@ -249,10 +249,10 @@ func Test_Agent_Namespace_test1(t *testing.T) { CreateActionDocument(t, ctx, srv, actionData) t.Log("Checkin so that agent gets the SETTINGS action") - actionID = AgentCheckin(t, ctx, srvCopy, agentID, key) + actionID = AgentCheckin(t, ctx, srvCopy, resp.Item.Id, resp.Item.AccessApiKey) t.Log("Ack so that fleet create the action results") - AgentAck(t, ctx, srvCopy, actionID, agentID, key) + AgentAck(t, ctx, srvCopy, actionID, resp.Item.Id, resp.Item.AccessApiKey) t.Log("Check action results has the correct namespace") CheckActionResultsNamespace(t, ctx, srv, actionID, "test1") diff --git a/internal/pkg/server/remote_es_output_integration_test.go b/internal/pkg/server/remote_es_output_integration_test.go index d9661787a..27d606502 100644 --- a/internal/pkg/server/remote_es_output_integration_test.go +++ b/internal/pkg/server/remote_es_output_integration_test.go @@ -214,22 +214,22 @@ func Test_Agent_Remote_ES_Output(t *testing.T) { t.Log("Enroll agent") srvCopy := srv srvCopy.enrollKey = newKey.Token() - agentID, key := EnrollAgent(t, ctx, srvCopy, enrollBody) + resp := EnrollAgent(t, ctx, srvCopy, enrollBody) // cleanup defer func() { - err = srv.bulker.Delete(ctx, dl.FleetAgents, agentID) + err = srv.bulker.Delete(ctx, dl.FleetAgents, resp.Item.Id) if err != nil { t.Log("could not clean up agent") } }() - remoteAPIKey, actionID := Checkin(t, ctx, srvCopy, agentID, key, true, "POLICY_CHANGE") + remoteAPIKey, actionID := Checkin(t, ctx, srvCopy, resp.Item.Id, resp.Item.AccessApiKey, true, "POLICY_CHANGE") apiKeyID := strings.Split(remoteAPIKey, ":")[0] verifyRemoteAPIKey(t, ctx, apiKeyID, false) - Ack(t, ctx, srvCopy, actionID, agentID, key) + Ack(t, ctx, srvCopy, actionID, resp.Item.Id, resp.Item.AccessApiKey) t.Log("Update policy to remove remote ES output") @@ -254,10 +254,10 @@ func Test_Agent_Remote_ES_Output(t *testing.T) { } t.Log("Checkin so that agent gets new policy revision") - _, actionID = Checkin(t, ctx, srvCopy, agentID, key, false, "POLICY_CHANGE") + _, actionID = Checkin(t, ctx, srvCopy, resp.Item.Id, resp.Item.AccessApiKey, false, "POLICY_CHANGE") t.Log("Ack so that fleet triggers remote api key invalidate") - Ack(t, ctx, srvCopy, actionID, agentID, key) + Ack(t, ctx, srvCopy, actionID, resp.Item.Id, resp.Item.AccessApiKey) verifyRemoteAPIKey(t, ctx, apiKeyID, true) @@ -368,22 +368,22 @@ func Test_Agent_Remote_ES_Output_ForceUnenroll(t *testing.T) { t.Log("Enroll agent") srvCopy := srv srvCopy.enrollKey = newKey.Token() - agentID, key := EnrollAgent(t, ctx, srvCopy, enrollBody) + resp := EnrollAgent(t, ctx, srvCopy, enrollBody) // cleanup defer func() { - err = srv.bulker.Delete(ctx, dl.FleetAgents, agentID) + err = srv.bulker.Delete(ctx, dl.FleetAgents, resp.Item.Id) if err != nil { t.Log("could not clean up agent") } }() - remoteAPIKey, actionID := Checkin(t, ctx, srvCopy, agentID, key, true, "POLICY_CHANGE") + remoteAPIKey, actionID := Checkin(t, ctx, srvCopy, resp.Item.Id, resp.Item.AccessApiKey, true, "POLICY_CHANGE") apiKeyID := strings.Split(remoteAPIKey, ":")[0] verifyRemoteAPIKey(t, ctx, apiKeyID, false) - Ack(t, ctx, srvCopy, actionID, agentID, key) + Ack(t, ctx, srvCopy, actionID, resp.Item.Id, resp.Item.AccessApiKey) t.Log("Force Unenroll agent - set inactive") @@ -392,17 +392,17 @@ func Test_Agent_Remote_ES_Output_ForceUnenroll(t *testing.T) { } body, err := doc.Marshal() require.NoError(t, err) - err = srv.bulker.Update(ctx, dl.FleetAgents, agentID, body, bulk.WithRefresh(), bulk.WithRetryOnConflict(3)) + err = srv.bulker.Update(ctx, dl.FleetAgents, resp.Item.Id, body, bulk.WithRefresh(), bulk.WithRetryOnConflict(3)) require.NoError(t, err) t.Log("Checkin so that invalidate logic runs") cli := cleanhttp.DefaultClient() - t.Logf("Fake a checkin for agent %s", agentID) - req, err := http.NewRequestWithContext(ctx, "POST", srv.baseURL()+"/api/fleet/agents/"+agentID+"/checkin", strings.NewReader(checkinBody)) + t.Logf("Fake a checkin for agent %s", resp.Item.Id) + req, err := http.NewRequestWithContext(ctx, "POST", srv.baseURL()+"/api/fleet/agents/"+resp.Item.Id+"/checkin", strings.NewReader(checkinBody)) require.NoError(t, err) - req.Header.Set("Authorization", "ApiKey "+key) + req.Header.Set("Authorization", "ApiKey "+resp.Item.AccessApiKey) req.Header.Set("User-Agent", "elastic agent "+serverVersion) req.Header.Set("Content-Type", "application/json") res, err := cli.Do(req) @@ -489,22 +489,22 @@ func Test_Agent_Remote_ES_Output_Unenroll(t *testing.T) { t.Log("Enroll agent") srvCopy := srv srvCopy.enrollKey = newKey.Token() - agentID, key := EnrollAgent(t, ctx, srvCopy, enrollBody) + resp := EnrollAgent(t, ctx, srvCopy, enrollBody) // cleanup defer func() { - err = srv.bulker.Delete(ctx, dl.FleetAgents, agentID) + err = srv.bulker.Delete(ctx, dl.FleetAgents, resp.Item.Id) if err != nil { t.Log("could not clean up agent") } }() - remoteAPIKey, actionID := Checkin(t, ctx, srvCopy, agentID, key, true, "POLICY_CHANGE") + remoteAPIKey, actionID := Checkin(t, ctx, srvCopy, resp.Item.Id, resp.Item.AccessApiKey, true, "POLICY_CHANGE") apiKeyID := strings.Split(remoteAPIKey, ":")[0] verifyRemoteAPIKey(t, ctx, apiKeyID, false) - Ack(t, ctx, srvCopy, actionID, agentID, key) + Ack(t, ctx, srvCopy, actionID, resp.Item.Id, resp.Item.AccessApiKey) t.Log("Unenroll agent") @@ -514,18 +514,18 @@ func Test_Agent_Remote_ES_Output_Unenroll(t *testing.T) { "@timestamp": "2023-12-11T13:00:00.000Z", "expiration": "2099-01-10T13:14:36.565Z", "type": "UNENROLL" - }`, agentID) + }`, resp.Item.Id) client := srv.bulker.Client() res, err := client.Index(".fleet-actions", strings.NewReader(doc)) require.NoError(t, err) require.Equal(t, 201, res.StatusCode) t.Log("Checkin so that agent gets unenroll action") - _, actionID = Checkin(t, ctx, srvCopy, agentID, key, false, "UNENROLL") + _, actionID = Checkin(t, ctx, srvCopy, resp.Item.Id, resp.Item.AccessApiKey, false, "UNENROLL") t.Log(actionID) t.Log("Ack so that fleet triggers remote api key invalidate") - Ack(t, ctx, srvCopy, actionID, agentID, key) + Ack(t, ctx, srvCopy, actionID, resp.Item.Id, resp.Item.AccessApiKey) t.Log("Verify that remote API key is invalidated") verifyRemoteAPIKey(t, ctx, apiKeyID, true) diff --git a/model/openapi.yml b/model/openapi.yml index bc936d43e..e6a382db9 100644 --- a/model/openapi.yml +++ b/model/openapi.yml @@ -139,6 +139,14 @@ components: - type - metadata properties: + id: + type: string + description: | + The ID of the agent. + This is the ID that will be used to reference this agent, if no ID is passed one will be generated. + If another agent is enrolled with the same ID the other agent will no longer be able to communicate, + this new agent is considered a replacement of the other agent. The other agent will be able to continue + sending data to ES. type: description: | The enrollment type of the agent. @@ -147,6 +155,12 @@ components: type: string enum: - PERMANENT + replace_token: + type: string + description: | + The replacement token of the agent. + Provided when an agent could replace an existing agent. This token must match the original enrollment of + that agent otherwise it will not be able to enroll. enrollment_id: type: string description: | diff --git a/model/schema.json b/model/schema.json index e979b2e6a..6fb03c87c 100644 --- a/model/schema.json +++ b/model/schema.json @@ -685,6 +685,10 @@ "upgrade_details": { "description": "Additional upgrade status details.", "type": "object" + }, + "replace_token": { + "description": "hash of token provided during enrollment that allows replacement by another enrollment with same ID", + "type": "string" } }, "required": ["_id", "type", "active", "enrolled_at", "status"] diff --git a/pkg/api/types.gen.go b/pkg/api/types.gen.go index 40fc1a92e..1bcaa4860 100644 --- a/pkg/api/types.gen.go +++ b/pkg/api/types.gen.go @@ -396,9 +396,21 @@ type EnrollRequest struct { // The existing agent with a matching enrollment_id will be deleted if it never checked in. The new agent will be enrolled with the enrollment_id. EnrollmentId *string `json:"enrollment_id,omitempty"` + // Id The ID of the agent. + // This is the ID that will be used to reference this agent, if no ID is passed one will be generated. + // If another agent is enrolled with the same ID the other agent will no longer be able to communicate, + // this new agent is considered a replacement of the other agent. The other agent will be able to continue + // sending data to ES. + Id *string `json:"id,omitempty"` + // Metadata Metadata associated with the agent that is enrolling to fleet. Metadata EnrollMetadata `json:"metadata"` + // ReplaceToken The replacement token of the agent. + // Provided when an agent could replace an existing agent. This token must match the original enrollment of + // that agent otherwise it will not be able to enroll. + ReplaceToken *string `json:"replace_token,omitempty"` + // SharedId The shared ID of the agent. // To support pre-existing installs. //