Skip to content

Commit

Permalink
Tamper protected Endpoint uninstall - combined PR (#2781)
Browse files Browse the repository at this point in the history
* Pass uninstall token from Agent uninstall command to Endpoint uninstall command

* Support singed UNENROLL action and UNENROLL forwarding to Endpoint

* Tamper protected Endpoint integration removal

* Rename optional_actions to proxied_actions

* Cleanup

* Address code review, add missing comments for the public structures and variables

* Change uninstall to fail the Agent uninstall if service (Endpoint) uninstall fails

* Fix comments typos

* Final touches after all testing

* Implement tamper protected agent upgrade

* Address code review feedback

* Make linter happy

* Added missing copyright header

* Feature flag

* Code fix after remerge with main changes

* Restore logging statement that was unnecessarily changed

* Address code review feedback

* Implement UNENROLL/UPGRADE actions dispatch with backoff and timeout

* Adjust backoff retry, more unit tests coverage

* Make linter happy

* Updated spec doc

* Rename backoffActionDispatcher to proxiedActionsNotifier, and making notify private

* Address code review

* Fix typo for linter

* Address code review comments

* Remove unused funcs

* Fix unit test

* Updated components specs doc, with more clarification for the proxied actions

* Update uninstall-token flag description
  • Loading branch information
aleksmaus authored Jul 20, 2023
1 parent e6d6dce commit a754cc7
Show file tree
Hide file tree
Showing 30 changed files with 1,973 additions and 124 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# 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: Tamper protected Endpoint uninstall

# 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: |
Add new `--uninstall-token` to allow uninstall when Endpoint protection is enabled.
Enable unenroll and upgrade actions to complete successfully when Endpoint protection is enabled.
Enable Endpoint integration removal when Endpoint protection is enabled.
# Affected component; a word indicating the component this changeset affects.
component:

# 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/elastic-agent/pull/2781
11 changes: 11 additions & 0 deletions docs/component-specs.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,17 @@ The platforms this input or shipper supports. Must contain one or more of the fo

The output types this input or shipper supports. If this is an input, then inputs of this type can only target (non-shipper) output types in this list. If this is a shipper, then this shipper can only implement output types in this list.

### `proxied_actions` (list of strings)

The action types that should be forwarded to the corresponding component. Currently these actions types are sent ("proxied") to the components in parallel. The agent action handler awaits for actions responses. If any of the proxied actions fail, the action is considered failed by the agent. Inital application for this was forwarding the Agent actions such as UNENROLL and UPGRADE to Endpoint service as a part of the Agent/Endpoint tamper protection feature.

Example for Endpoint spec:
```
proxied_actions:
- UNENROLL
- UPGRADE
```

### `shippers` (list of strings, input only)

The shipper types this input supports. Inputs of this type can target any output type supported by the shippers in this list, as long as the output policy includes `shipper.enabled: true`. If an input supports more than one shipper implementing the same output type, then Agent will prefer the one that appears first in this list.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/elastic/elastic-agent/internal/pkg/fleetapi"
"github.com/elastic/elastic-agent/internal/pkg/fleetapi/acker"
"github.com/elastic/elastic-agent/pkg/core/logger"
"github.com/elastic/elastic-agent/pkg/features"
)

const (
Expand All @@ -32,23 +33,29 @@ type stateStore interface {
// For it to be operational again it needs to be either enrolled or reconfigured.
type Unenroll struct {
log *logger.Logger
coord actionCoordinator
ch chan coordinator.ConfigChange
closers []context.CancelFunc
stateStore stateStore

tamperProtectionFn func() bool // allows to inject the flag for tests, defaults to features.TamperProtection
}

// NewUnenroll creates a new Unenroll handler.
func NewUnenroll(
log *logger.Logger,
coord actionCoordinator,
ch chan coordinator.ConfigChange,
closers []context.CancelFunc,
stateStore stateStore,
) *Unenroll {
return &Unenroll{
log: log,
ch: ch,
closers: closers,
stateStore: stateStore,
log: log,
coord: coord,
ch: ch,
closers: closers,
stateStore: stateStore,
tamperProtectionFn: features.TamperProtection,
}
}

Expand All @@ -60,11 +67,28 @@ func (h *Unenroll) Handle(ctx context.Context, a fleetapi.Action, acker acker.Ac
return fmt.Errorf("invalid type, expected ActionUnenroll and received %T", a)
}

if h.tamperProtectionFn() {
// Find inputs that want to receive UNENROLL action
// Endpoint needs to receive a signed UNENROLL action in order to be able to uncontain itself
state := h.coord.State()
ucs := findMatchingUnitsByActionType(state, a.Type())
if len(ucs) > 0 {
err := notifyUnitsOfProxiedAction(ctx, h.log, action, ucs, h.coord.PerformAction)
if err != nil {
return err
}
} else {
// Log and continue
h.log.Debugf("No components running for %v action type", a.Type())
}
}

if action.IsDetected {
// not from Fleet; so we set it to nil so policyChange doesn't ack it
a = nil
}

// Generate empty policy change, this removing all the running components
unenrollPolicy := newPolicyChange(ctx, config.New(), a, acker, true)
h.ch <- unenrollPolicy

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
// or more contributor license agreements. Licensed under the Elastic License;
// you may not use this file except in compliance with the Elastic License.

package handlers

import (
"context"
"testing"

"github.com/elastic/elastic-agent-client/v7/pkg/client"
"github.com/elastic/elastic-agent-client/v7/pkg/proto"
"github.com/elastic/elastic-agent/internal/pkg/agent/application/coordinator"
"github.com/elastic/elastic-agent/internal/pkg/fleetapi"
"github.com/elastic/elastic-agent/pkg/component"
"github.com/elastic/elastic-agent/pkg/component/runtime"
"github.com/elastic/elastic-agent/pkg/core/logger"

"github.com/stretchr/testify/require"
)

func makeComponentState(name string, proxiedActions []string) runtime.ComponentComponentState {
return runtime.ComponentComponentState{
Component: component.Component{
InputType: name,
Units: []component.Unit{
{
Type: client.UnitTypeInput,
Config: &proto.UnitExpectedConfig{Type: name},
},
},
InputSpec: &component.InputRuntimeSpec{
Spec: component.InputSpec{
Name: name,
ProxiedActions: proxiedActions,
},
},
},
}
}

type MockActionCoordinator struct {
st coordinator.State
performedActions int
}

func (c *MockActionCoordinator) State() coordinator.State {
return c.st
}

func (c *MockActionCoordinator) PerformAction(ctx context.Context, comp component.Component, unit component.Unit, name string, params map[string]interface{}) (map[string]interface{}, error) {
c.performedActions++
return nil, nil
}

func (c *MockActionCoordinator) Clear() {
c.performedActions = 0
}

type MockAcker struct {
Acked []fleetapi.Action
}

func (m *MockAcker) Ack(_ context.Context, action fleetapi.Action) error {
m.Acked = append(m.Acked, action)
return nil
}

func (m *MockAcker) Commit(_ context.Context) error {
return nil
}

func (m *MockAcker) Clear() {
m.Acked = nil
}

func TestActionUnenrollHandler(t *testing.T) {
ctx, cn := context.WithCancel(context.Background())
defer cn()

log, _ := logger.New("", false)
coord := &MockActionCoordinator{}
acker := &MockAcker{}

action := &fleetapi.ActionUnenroll{
ActionID: "c80e9219-70bf-43d3-b8cd-b5131a771751",
ActionType: "UNENROLL",
}
goodSigned := &fleetapi.Signed{
Data: "eyJAdGltZXN0YW1wIjoiMjAyMy0wNS0yMlQxNzoxOToyOC40NjNaIiwiZXhwaXJhdGlvbiI6IjIwMjMtMDYtMjFUMTc6MTk6MjguNDYzWiIsImFnZW50cyI6WyI3ZjY0YWI2NC1hNmM0LTQ2ZTMtODIyYS0zODUxZGVkYTJmY2UiXSwiYWN0aW9uX2lkIjoiNGYwODQ2MGYtMDE0Yy00ZDllLWJmOGEtY2FhNjQyNzRhZGU0IiwidHlwZSI6IlVORU5ST0xMIiwidHJhY2VwYXJlbnQiOiIwMC1iOTBkYTlmOGNjNzdhODk0OTc0ZWIxZTIzMGNmNjc2Yy1lOTNlNzk4YTU4ODg2MDVhLTAxIn0=",
Signature: "MEUCIAxxsi9ff1zyV0+4fsJLqbP8Qb83tedU5iIFldtxEzEfAiEA0KUsrL7q+Fv7z6Boux3dY2P4emGi71jsMGanIZ552bM=",
}
action.Signed = goodSigned

ch := make(chan coordinator.ConfigChange, 1)
go func() {
for {
select {
case <-ctx.Done():
return
case policyChange := <-ch:
_ = policyChange.Ack()
}
}
}()

handler := NewUnenroll(log, coord, ch, nil, nil)

getTamperProtectionFunc := func(enabled bool) func() bool {
return func() bool {
return enabled
}
}

tests := []struct {
name string
st coordinator.State
wantErr error // Handler error
wantPerformedActions int
tamperProtectionFn func() bool
}{
{
name: "no running components",
},
{
name: "endpoint no dispatch",
st: func() coordinator.State {
return coordinator.State{
Components: []runtime.ComponentComponentState{
makeComponentState("endpoint", nil),
},
}
}(),
},
{
name: "endpoint with UNENROLL, tamper protection feature flag disabled",
st: func() coordinator.State {
return coordinator.State{
Components: []runtime.ComponentComponentState{
makeComponentState("endpoint", []string{"UNENROLL"}),
makeComponentState("osquery", nil),
},
}
}(),
wantPerformedActions: 0,
},
{
name: "endpoint with UNENROLL, tamper protection feature flag enabled",
st: func() coordinator.State {
return coordinator.State{
Components: []runtime.ComponentComponentState{
makeComponentState("endpoint", []string{"UNENROLL"}),
makeComponentState("osquery", nil),
},
}
}(),
tamperProtectionFn: getTamperProtectionFunc(true),
wantPerformedActions: 1,
},
{
name: "more than one UNENROLL dispatch, tamper protection feature flag disabled",
st: func() coordinator.State {
return coordinator.State{
Components: []runtime.ComponentComponentState{
makeComponentState("endpoint", []string{"UNENROLL"}),
makeComponentState("foobar", []string{"UNENROLL", "FOOBAR"}),
makeComponentState("osquery", nil),
},
}
}(),
wantPerformedActions: 0,
},
{
name: "more than one UNENROLL dispatch, tamper protection feature flag enabled",
st: func() coordinator.State {
return coordinator.State{
Components: []runtime.ComponentComponentState{
makeComponentState("endpoint", []string{"UNENROLL"}),
makeComponentState("foobar", []string{"UNENROLL", "FOOBAR"}),
makeComponentState("osquery", nil),
},
}
}(),
tamperProtectionFn: getTamperProtectionFunc(true),
wantPerformedActions: 2,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
defer acker.Clear()
defer coord.Clear()

coord.st = tc.st

if tc.tamperProtectionFn == nil {
handler.tamperProtectionFn = getTamperProtectionFunc(false)
} else {
handler.tamperProtectionFn = tc.tamperProtectionFn
}

err := handler.Handle(ctx, action, acker)

require.ErrorIs(t, err, tc.wantErr)
if tc.wantErr == nil {
require.Len(t, acker.Acked, 1)
}
require.Equal(t, tc.wantPerformedActions, coord.performedActions)
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,28 +10,31 @@ import (
"fmt"
"sync"

"github.com/elastic/elastic-agent/internal/pkg/agent/application/coordinator"
"github.com/elastic/elastic-agent/internal/pkg/fleetapi"
"github.com/elastic/elastic-agent/internal/pkg/fleetapi/acker"
"github.com/elastic/elastic-agent/pkg/core/logger"
"github.com/elastic/elastic-agent/pkg/features"
)

// Upgrade is a handler for UPGRADE action.
// After running Upgrade agent should download its own version specified by action
// from repository specified by fleet.
type Upgrade struct {
log *logger.Logger
coord *coordinator.Coordinator
coord upgradeCoordinator
bkgActions []fleetapi.Action
bkgCancel context.CancelFunc
bkgMutex sync.Mutex

tamperProtectionFn func() bool // allows to inject the flag for tests, defaults to features.TamperProtection
}

// NewUpgrade creates a new Upgrade handler.
func NewUpgrade(log *logger.Logger, coord *coordinator.Coordinator) *Upgrade {
func NewUpgrade(log *logger.Logger, coord upgradeCoordinator) *Upgrade {
return &Upgrade{
log: log,
coord: coord,
log: log,
coord: coord,
tamperProtectionFn: features.TamperProtection,
}
}

Expand All @@ -51,6 +54,25 @@ func (h *Upgrade) Handle(ctx context.Context, a fleetapi.Action, ack acker.Acker
if !runAsync {
return nil
}

if h.tamperProtectionFn() {
// Find inputs that want to receive UPGRADE action
// Endpoint needs to receive a signed UPGRADE action in order to be able to uncontain itself
state := h.coord.State()
ucs := findMatchingUnitsByActionType(state, a.Type())
if len(ucs) > 0 {
h.log.Debugf("handlerUpgrade: proxy/dispatch action '%+v'", a)
err := notifyUnitsOfProxiedAction(ctx, h.log, action, ucs, h.coord.PerformAction)
h.log.Debugf("handlerUpgrade: after action dispatched '%+v', err: %v", a, err)
if err != nil {
return err
}
} else {
// Log and continue
h.log.Debugf("No components running for %v action type", a.Type())
}
}

go func() {
h.log.Infof("starting upgrade to version %s in background", action.Version)
if err := h.coord.Upgrade(asyncCtx, action.Version, action.SourceURI, action, false); err != nil {
Expand Down
Loading

0 comments on commit a754cc7

Please sign in to comment.