Skip to content

Pass --header enrollment option to fleet-server #8071

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
Jul 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# 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: bug-fix

# Change summary; a 80ish characters long description of the change.
summary: Use --header from enrollment when communicating with Fleet Server

# 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: |
The --header option for the enrollment command now adds the headers to the communication with Fleet Server. This
allows a proxy that requires specific headers present for traffic to flow to be placed in front of a Fleet Server
to be used and still allowing the Elastic Agent to enroll.

# Affected component; usually one of "elastic-agent", "fleet-server", "filebeat", "metricbeat", "auditbeat", "all", etc.
component: elastic-agent

# 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/8071

# 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/elastic-agent/issues/6823
3 changes: 3 additions & 0 deletions internal/pkg/agent/cmd/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,9 @@ func buildEnrollArgs(cfg setupConfig, token string, policyID string) ([]string,
if cfg.Fleet.Insecure {
args = append(args, "--insecure")
}
for k, v := range cfg.Fleet.Headers {
args = append(args, "--header", k+"="+v)
}
}
if cfg.Fleet.CA != "" {
args = append(args, "--certificate-authorities", cfg.Fleet.CA)
Expand Down
3 changes: 2 additions & 1 deletion internal/pkg/agent/cmd/enroll.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ func addEnrollFlags(cmd *cobra.Command) {
cmd.Flags().StringP("fleet-server-cert-key", "", "", "Private key for the certificate used by Fleet Server for exposed HTTPS endpoint")
cmd.Flags().StringP("fleet-server-cert-key-passphrase", "", "", "Path for private key passphrase file used to decrypt Fleet Server certificate key")
cmd.Flags().StringP("fleet-server-client-auth", "", "none", "Fleet Server mTLS client authentication for connecting Elastic Agents. Must be one of [none, optional, required]")
cmd.Flags().StringSliceP("header", "", []string{}, "Headers used by Fleet Server when communicating with Elasticsearch")
cmd.Flags().StringSliceP("header", "", []string{}, "Headers used by Agent to communicate with Fleet Server, and when a bootstrapped Fleet Server communicates with Elasticsearch")
cmd.Flags().BoolP("fleet-server-insecure-http", "", false, "Expose Fleet Server over HTTP (not recommended; insecure)")
cmd.Flags().StringP("certificate-authorities", "a", "", "Comma-separated list of root certificates for server verification used by Elastic Agent and Fleet Server")
cmd.Flags().StringP("ca-sha256", "p", "", "Comma-separated list of certificate authority hash pins for server verification used by Elastic Agent and Fleet Server")
Expand Down Expand Up @@ -522,6 +522,7 @@ func enroll(streams *cli.IOStreams, cmd *cobra.Command) error {
UserProvidedMetadata: make(map[string]interface{}),
Staging: staging,
FixPermissions: fixPermissions,
Headers: mapFromEnvList(fHeaders),
ProxyURL: proxyURL,
ProxyDisabled: proxyDisabled,
ProxyHeaders: mapFromEnvList(proxyHeaders),
Expand Down
2 changes: 2 additions & 0 deletions internal/pkg/agent/cmd/enroll_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ type enrollCmdOption struct {
ReplaceToken string `yaml:"replace_token,omitempty"`
EnrollAPIKey string `yaml:"enrollment_key,omitempty"`
Staging string `yaml:"staging,omitempty"`
Headers map[string]string `yaml:"headers,omitempty"`
ProxyURL string `yaml:"proxy_url,omitempty"`
ProxyDisabled bool `yaml:"proxy_disabled,omitempty"`
ProxyHeaders map[string]string `yaml:"proxy_headers,omitempty"`
Expand All @@ -144,6 +145,7 @@ func (e *enrollCmdOption) remoteConfig() (remote.Config, error) {
if cfg.Protocol == remote.ProtocolHTTP && !e.Insecure {
return remote.Config{}, fmt.Errorf("connection to fleet-server is insecure, strongly recommended to use a secure connection (override with --insecure)")
}
cfg.Headers = e.Headers

var tlsCfg tlscommon.Config

Expand Down
73 changes: 73 additions & 0 deletions internal/pkg/agent/cmd/enroll_cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,79 @@ func TestEnroll(t *testing.T) {
store.AssertExpectations(t)
},
))

t.Run("headers are sent to server", withServer(
func(t *testing.T) *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("/api/fleet/agents/enroll", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Test-Header") != "Test-Value" {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`
{
"statusCode": 500,
"error": "Missing required X-Test-Header header"
}`))
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`
{
"action": "created",
"item": {
"id": "a9328860-ec54-11e9-93c4-d72ab8a69391",
"active": true,
"policy_id": "69f3f5a0-ec52-11e9-93c4-d72ab8a69391",
"type": "PERMANENT",
"enrolled_at": "2019-10-11T18:26:37.158Z",
"user_provided_metadata": {
"custom": "customize"
},
"local_metadata": {
"platform": "linux",
"version": "8.0.0"
},
"actions": [],
"access_api_key": "my-access-api-key"
}
}`))
})
return mux
}, func(t *testing.T, host string) {
url := "http://" + host
store := &mockStore{}
cmd, err := newEnrollCmd(
log,
&enrollCmdOption{
URL: url,
CAs: []string{},
EnrollAPIKey: "my-enrollment-api-key",
Insecure: true,
UserProvidedMetadata: map[string]interface{}{"custom": "customize"},
SkipCreateSecret: skipCreateSecret,
SkipDaemonRestart: true,
Headers: map[string]string{
"X-Test-Header": "Test-Value",
},
},
"",
store,
nil,
)
require.NoError(t, err)

streams, _, _, _ := cli.NewTestingIOStreams()
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
defer cancel()
err = cmd.Execute(ctx, streams)
require.NoError(t, err, "enroll command should return no error")

assert.True(t, store.Called, "the store should have been called")
config, err := readConfig(store.Content)
require.NoError(t, err)
assert.Equal(t, "my-access-api-key", config.AccessAPIKey)
assert.Equal(t, host, config.Client.Host)
},
))
}

func TestValidateArgs(t *testing.T) {
Expand Down
30 changes: 16 additions & 14 deletions internal/pkg/agent/cmd/setup_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,21 @@ type setupConfig struct {
}

type fleetConfig struct {
CA string `config:"ca"`
Enroll bool `config:"enroll"`
EnrollmentToken string `config:"enrollment_token"`
ID string `config:"id"`
ReplaceToken string `config:"replace_token"`
Force bool `config:"force"`
Insecure bool `config:"insecure"`
TokenName string `config:"token_name"`
TokenPolicyName string `config:"token_policy_name"`
URL string `config:"url"`
DaemonTimeout time.Duration `config:"daemon_timeout"`
EnrollTimeout time.Duration `config:"enroll_timeout"`
Cert string `config:"cert"`
CertKey string `config:"cert_key"`
CA string `config:"ca"`
Enroll bool `config:"enroll"`
EnrollmentToken string `config:"enrollment_token"`
ID string `config:"id"`
ReplaceToken string `config:"replace_token"`
Force bool `config:"force"`
Insecure bool `config:"insecure"`
TokenName string `config:"token_name"`
TokenPolicyName string `config:"token_policy_name"`
URL string `config:"url"`
Headers map[string]string `config:"headers"`
DaemonTimeout time.Duration `config:"daemon_timeout"`
EnrollTimeout time.Duration `config:"enroll_timeout"`
Cert string `config:"cert"`
CertKey string `config:"cert_key"`
}

type fleetServerConfig struct {
Expand Down Expand Up @@ -96,6 +97,7 @@ func defaultAccessConfig() (setupConfig, error) {
TokenName: envWithDefault("Default", "FLEET_TOKEN_NAME"),
TokenPolicyName: envWithDefault("", "FLEET_TOKEN_POLICY_NAME"),
URL: envWithDefault("", "FLEET_URL"),
Headers: envMap("FLEET_HEADER"),
DaemonTimeout: envTimeout("FLEET_DAEMON_TIMEOUT"),
EnrollTimeout: envTimeout("FLEET_ENROLL_TIMEOUT"),
Cert: envWithDefault("", "ELASTIC_AGENT_CERT"),
Expand Down
3 changes: 2 additions & 1 deletion internal/pkg/diagnostics/diagnostics.go
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,8 @@ func redactKey(k string) bool {
}

k = strings.ToLower(k)
return strings.Contains(k, "certificate") ||
return strings.Contains(k, "auth") ||
strings.Contains(k, "certificate") ||
strings.Contains(k, "passphrase") ||
strings.Contains(k, "password") ||
strings.Contains(k, "token") ||
Expand Down
6 changes: 4 additions & 2 deletions internal/pkg/diagnostics/diagnostics_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,10 @@ i4EFZLWrFRsAAAARYWxleGtAZ3JlbWluLm5lc3QBAg==
"nested1": mapstr.M{
"certificate": "unredacted",
"nested2": mapstr.M{
"passphrase": "unredacted",
"password": "unredacted",
"X-Authentication": "unredacted",
"X-App-Auth": "unredacted",
"passphrase": "unredacted",
"password": "unredacted",
"nested3": mapstr.M{
"token": "unredacted",
"key": "unredacted",
Expand Down
45 changes: 45 additions & 0 deletions internal/pkg/fleetapi/checkin_cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (

"github.com/elastic/elastic-agent/internal/pkg/agent/application/info"
"github.com/elastic/elastic-agent/internal/pkg/fleetapi/client"
"github.com/elastic/elastic-agent/internal/pkg/remote"
)

type agentinfo struct{}
Expand Down Expand Up @@ -269,4 +270,48 @@ func TestCheckin(t *testing.T) {
require.Equal(t, 0, len(r.Actions))
},
))

t.Run("Headers are sent", withServerWithAuthClient(
func(t *testing.T) *http.ServeMux {
raw := `{"actions": []}`
mux := http.NewServeMux()
path := fmt.Sprintf("/api/fleet/agents/%s/checkin", agentInfo.AgentID())
mux.HandleFunc(path, authHandler(func(w http.ResponseWriter, r *http.Request) {
type Request struct {
Metadata *info.ECSMeta `json:"local_metadata"`
}

var req *Request

content, err := io.ReadAll(r.Body)
assert.NoError(t, err)
assert.NoError(t, json.Unmarshal(content, &req))
assert.Nil(t, req.Metadata)

authHeader, ok := r.Header["X-App-Auth"]
if assert.True(t, ok) && assert.Len(t, authHeader, 1) {
assert.Equal(t, "auth-token-123", authHeader[0])
}

w.WriteHeader(http.StatusOK)
fmt.Fprint(w, raw)
}, withAPIKey))
return mux
}, withAPIKey,
func(t *testing.T, client client.Sender) {
cmd := NewCheckinCmd(agentInfo, client)

request := CheckinRequest{}

r, _, err := cmd.Execute(ctx, &request)
require.NoError(t, err)

require.Equal(t, 0, len(r.Actions))
},
func(config *remote.Config) {
config.Headers = map[string]string{
"X-App-Auth": "auth-token-123",
}
},
))
}
4 changes: 4 additions & 0 deletions internal/pkg/fleetapi/helper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,17 @@ func withServerWithAuthClient(
m func(t *testing.T) *http.ServeMux,
apiKey string,
test func(t *testing.T, client client.Sender),
configMod ...func(*remote.Config),
) func(t *testing.T) {

return withServer(m, func(t *testing.T, host string) {
log, _ := logger.New("", false)
cfg := remote.Config{
Host: host,
}
for _, mod := range configMod {
mod(&cfg)
}

client, err := client.NewAuthWithConfig(log, apiKey, cfg)
require.NoError(t, err)
Expand Down
16 changes: 16 additions & 0 deletions internal/pkg/remote/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@ func NewWithConfig(log *logger.Logger, cfg Config, wrapper wrapperFunc) (*Client
return nil, err
}

if cfg.Headers != nil {
transport = &headersRoundTripper{rt: transport, headers: cfg.Headers}
}

if wrapper != nil {
transport, err = wrapper(transport)
if err != nil {
Expand Down Expand Up @@ -345,3 +349,15 @@ func (r requestClient) newRequest(method string, path string, params url.Values,

return http.NewRequestWithContext(context.TODO(), method, newPath, body)
}

type headersRoundTripper struct {
rt http.RoundTripper
headers map[string]string
}

func (r *headersRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
for key, value := range r.headers {
req.Header.Set(key, value)
}
return r.rt.RoundTrip(req)
}
11 changes: 6 additions & 5 deletions internal/pkg/remote/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@ import (

// Config is the configuration for the client.
type Config struct {
Protocol Protocol `config:"protocol" yaml:"protocol,omitempty"`
SpaceID string `config:"space.id" yaml:"space.id,omitempty"`
Path string `config:"path" yaml:"path,omitempty"`
Host string `config:"host" yaml:"host,omitempty"`
Hosts []string `config:"hosts" yaml:"hosts,omitempty"`
Protocol Protocol `config:"protocol" yaml:"protocol,omitempty"`
SpaceID string `config:"space.id" yaml:"space.id,omitempty"`
Path string `config:"path" yaml:"path,omitempty"`
Host string `config:"host" yaml:"host,omitempty"`
Hosts []string `config:"hosts" yaml:"hosts,omitempty"`
Headers map[string]string `config:"headers" yaml:"headers,omitempty"`

Transport httpcommon.HTTPTransportSettings `config:",inline" yaml:",inline"`
}
Expand Down