diff --git a/.changelog/14437.txt b/.changelog/14437.txt new file mode 100644 index 0000000000..c9584f641a --- /dev/null +++ b/.changelog/14437.txt @@ -0,0 +1,3 @@ +```release-note:improvement +acl: Added option to allow for an operator-generated bootstrap token to be passed to the `acl bootstrap` command. +``` \ No newline at end of file diff --git a/agent/acl_endpoint.go b/agent/acl_endpoint.go index d3fa62b12e..cd5664d58b 100644 --- a/agent/acl_endpoint.go +++ b/agent/acl_endpoint.go @@ -7,6 +7,7 @@ import ( "github.com/hashicorp/consul/acl" "github.com/hashicorp/consul/agent/structs" + "github.com/hashicorp/consul/api" "github.com/hashicorp/consul/lib" ) @@ -34,9 +35,19 @@ func (s *HTTPHandlers) ACLBootstrap(resp http.ResponseWriter, req *http.Request) return nil, aclDisabled } - args := structs.DCSpecificRequest{ + args := structs.ACLInitialTokenBootstrapRequest{ Datacenter: s.agent.config.Datacenter, } + + // Handle optional request body + if req.ContentLength > 0 { + var bootstrapSecretRequest api.BootstrapRequest + if err := lib.DecodeJSON(req.Body, &bootstrapSecretRequest); err != nil { + return nil, HTTPError{StatusCode: http.StatusBadRequest, Reason: fmt.Sprintf("Request decoding failed: %v", err)} + } + args.BootstrapSecret = bootstrapSecretRequest.BootstrapSecret + } + var out structs.ACLToken err := s.agent.RPC(req.Context(), "ACL.BootstrapTokens", &args, &out) if err != nil { diff --git a/agent/acl_endpoint_test.go b/agent/acl_endpoint_test.go index da1b5b685a..d5f57fd853 100644 --- a/agent/acl_endpoint_test.go +++ b/agent/acl_endpoint_test.go @@ -148,6 +148,64 @@ func TestACL_Bootstrap(t *testing.T) { } } +func TestACL_BootstrapWithToken(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + a := NewTestAgent(t, ` + primary_datacenter = "dc1" + + acl { + enabled = true + default_policy = "deny" + } + `) + defer a.Shutdown() + + tests := []struct { + name string + method string + code int + token bool + }{ + {"bootstrap", "PUT", http.StatusOK, true}, + {"not again", "PUT", http.StatusForbidden, false}, + } + testrpc.WaitForLeader(t, a.RPC, "dc1") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var bootstrapSecret struct { + BootstrapSecret string + } + bootstrapSecret.BootstrapSecret = "2b778dd9-f5f1-6f29-b4b4-9a5fa948757a" + resp := httptest.NewRecorder() + req, _ := http.NewRequest(tt.method, "/v1/acl/bootstrap", jsonBody(bootstrapSecret)) + out, err := a.srv.ACLBootstrap(resp, req) + if tt.token && err != nil { + t.Fatalf("err: %v", err) + } + if tt.token { + wrap, ok := out.(*aclBootstrapResponse) + if !ok { + t.Fatalf("bad: %T", out) + } + if wrap.ID != bootstrapSecret.BootstrapSecret { + t.Fatalf("bad: %v", wrap) + } + if wrap.ID != wrap.SecretID { + t.Fatalf("bad: %v", wrap) + } + } else { + if out != nil { + t.Fatalf("bad: %T", out) + } + } + }) + } +} + func TestACL_HTTP(t *testing.T) { if testing.Short() { t.Skip("too slow for testing.Short") diff --git a/agent/consul/acl_endpoint.go b/agent/consul/acl_endpoint.go index ee9551face..b009b8f914 100644 --- a/agent/consul/acl_endpoint.go +++ b/agent/consul/acl_endpoint.go @@ -162,7 +162,7 @@ func (a *ACL) aclPreCheck() error { // BootstrapTokens is used to perform a one-time ACL bootstrap operation on // a cluster to get the first management token. -func (a *ACL) BootstrapTokens(args *structs.DCSpecificRequest, reply *structs.ACLToken) error { +func (a *ACL) BootstrapTokens(args *structs.ACLInitialTokenBootstrapRequest, reply *structs.ACLToken) error { if err := a.aclPreCheck(); err != nil { return err } @@ -207,9 +207,24 @@ func (a *ACL) BootstrapTokens(args *structs.DCSpecificRequest, reply *structs.AC if err != nil { return err } - secret, err := lib.GenerateUUID(a.srv.checkTokenUUID) - if err != nil { - return err + secret := args.BootstrapSecret + if secret == "" { + secret, err = lib.GenerateUUID(a.srv.checkTokenUUID) + if err != nil { + return err + } + } else { + _, err = uuid.ParseUUID(secret) + if err != nil { + return err + } + ok, err := a.srv.checkTokenUUID(secret) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("Provided token cannot be used because a token with that secret already exists.") + } } req := structs.ACLTokenBootstrapRequest{ diff --git a/agent/consul/acl_endpoint_test.go b/agent/consul/acl_endpoint_test.go index 35be038578..06781e7cbf 100644 --- a/agent/consul/acl_endpoint_test.go +++ b/agent/consul/acl_endpoint_test.go @@ -38,7 +38,7 @@ func TestACLEndpoint_BootstrapTokens(t *testing.T) { waitForLeaderEstablishment(t, srv) // Expect an error initially since ACL bootstrap is not initialized. - arg := structs.DCSpecificRequest{ + arg := structs.ACLInitialTokenBootstrapRequest{ Datacenter: "dc1", } var out structs.ACLToken @@ -72,6 +72,53 @@ func TestACLEndpoint_BootstrapTokens(t *testing.T) { require.Equal(t, out.CreateIndex, out.ModifyIndex) } +func TestACLEndpoint_ProvidedBootstrapTokens(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + _, srv, codec := testACLServerWithConfig(t, func(c *Config) { + // remove this as we are bootstrapping + c.ACLInitialManagementToken = "" + }, false) + waitForLeaderEstablishment(t, srv) + + // Expect an error initially since ACL bootstrap is not initialized. + arg := structs.ACLInitialTokenBootstrapRequest{ + Datacenter: "dc1", + BootstrapSecret: "2b778dd9-f5f1-6f29-b4b4-9a5fa948757a", + } + var out structs.ACLToken + require.NoError(t, msgpackrpc.CallWithCodec(codec, "ACL.BootstrapTokens", &arg, &out)) + require.Equal(t, out.SecretID, arg.BootstrapSecret) + require.Equal(t, 36, len(out.AccessorID)) + require.True(t, strings.HasPrefix(out.Description, "Bootstrap Token")) + require.True(t, out.CreateIndex > 0) + require.Equal(t, out.CreateIndex, out.ModifyIndex) +} + +func TestACLEndpoint_ProvidedBootstrapTokensInvalid(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + _, srv, codec := testACLServerWithConfig(t, func(c *Config) { + // remove this as we are bootstrapping + c.ACLInitialManagementToken = "" + }, false) + waitForLeaderEstablishment(t, srv) + + // Expect an error initially since ACL bootstrap is not initialized. + arg := structs.ACLInitialTokenBootstrapRequest{ + Datacenter: "dc1", + BootstrapSecret: "abc", + } + var out structs.ACLToken + require.EqualError(t, msgpackrpc.CallWithCodec(codec, "ACL.BootstrapTokens", &arg, &out), "uuid string is wrong length") +} + func TestACLEndpoint_ReplicationStatus(t *testing.T) { if testing.Short() { t.Skip("too slow for testing.Short") diff --git a/agent/structs/acl.go b/agent/structs/acl.go index a5243c4758..f1b3a7d673 100644 --- a/agent/structs/acl.go +++ b/agent/structs/acl.go @@ -1351,10 +1351,20 @@ type ACLTokenBatchDeleteRequest struct { TokenIDs []string // Tokens to delete } +type ACLInitialTokenBootstrapRequest struct { + BootstrapSecret string + Datacenter string + QueryOptions +} + +func (r *ACLInitialTokenBootstrapRequest) RequestDatacenter() string { + return r.Datacenter +} + // ACLTokenBootstrapRequest is used only at the Raft layer // for ACL bootstrapping // -// The RPC layer will use a generic DCSpecificRequest to indicate +// The RPC layer will use ACLInitialTokenBootstrapRequest to indicate // that bootstrapping must be performed but the actual token // and the resetIndex will be generated by that RPC endpoint type ACLTokenBootstrapRequest struct { diff --git a/api/acl.go b/api/acl.go index ceafaddc2b..6c131093c6 100644 --- a/api/acl.go +++ b/api/acl.go @@ -498,10 +498,25 @@ func (c *Client) ACL() *ACL { return &ACL{c} } +// BootstrapRequest is used for when operators provide an ACL Bootstrap Token +type BootstrapRequest struct { + BootstrapSecret string +} + // Bootstrap is used to perform a one-time ACL bootstrap operation on a cluster // to get the first management token. func (a *ACL) Bootstrap() (*ACLToken, *WriteMeta, error) { + return a.BootstrapWithToken("") +} + +// BootstrapWithToken is used to get the initial bootstrap token or pass in the one that was provided in the API +func (a *ACL) BootstrapWithToken(btoken string) (*ACLToken, *WriteMeta, error) { r := a.c.newRequest("PUT", "/v1/acl/bootstrap") + if btoken != "" { + r.obj = &BootstrapRequest{ + BootstrapSecret: btoken, + } + } rtt, resp, err := a.c.doRequest(r) if err != nil { return nil, nil, err diff --git a/command/acl/bootstrap/bootstrap.go b/command/acl/bootstrap/bootstrap.go index 37ec63a9da..254ade15dc 100644 --- a/command/acl/bootstrap/bootstrap.go +++ b/command/acl/bootstrap/bootstrap.go @@ -3,10 +3,13 @@ package bootstrap import ( "flag" "fmt" + "os" "strings" + "github.com/hashicorp/consul/api" "github.com/hashicorp/consul/command/acl/token" "github.com/hashicorp/consul/command/flags" + "github.com/hashicorp/consul/command/helpers" "github.com/mitchellh/cli" ) @@ -43,13 +46,34 @@ func (c *cmd) Run(args []string) int { return 1 } + args = c.flags.Args() + if l := len(args); l < 0 || l > 1 { + c.UI.Error("This command takes up to one argument") + return 1 + } + + var terminalToken string + var err error + + if len(args) == 1 { + terminalToken, err = helpers.LoadDataSourceNoRaw(args[0], os.Stdin) + if err != nil { + c.UI.Error(fmt.Sprintf("Error reading provided token: %v", err)) + return 1 + } + } + + // Remove newline from the token if it was passed by stdin + boottoken := strings.TrimSpace(terminalToken) + client, err := c.http.APIClient() if err != nil { c.UI.Error(fmt.Sprintf("Error connecting to Consul agent: %s", err)) return 1 } - t, _, err := client.ACL().Bootstrap() + var t *api.ACLToken + t, _, err = client.ACL().BootstrapWithToken(boottoken) if err != nil { c.UI.Error(fmt.Sprintf("Failed ACL bootstrapping: %v", err)) return 1 diff --git a/command/acl/bootstrap/bootstrap_test.go b/command/acl/bootstrap/bootstrap_test.go index 8b6c7d4971..8d60289e86 100644 --- a/command/acl/bootstrap/bootstrap_test.go +++ b/command/acl/bootstrap/bootstrap_test.go @@ -2,6 +2,7 @@ package bootstrap import ( "encoding/json" + "os" "strings" "testing" @@ -87,3 +88,50 @@ func TestBootstrapCommand_JSON(t *testing.T) { err := json.Unmarshal([]byte(output), &jsonOutput) require.NoError(t, err, "token unmarshalling error") } + +func TestBootstrapCommand_Initial(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + + a := agent.NewTestAgent(t, ` + primary_datacenter = "dc1" + acl { + enabled = true + }`) + + defer a.Shutdown() + testrpc.WaitForLeader(t, a.RPC, "dc1") + + ui := cli.NewMockUi() + cmd := New(ui) + + // Create temp file + f, err := os.CreateTemp("", "consul-token.token") + assert.Nil(t, err) + defer os.Remove(f.Name()) + + // Write the token to the file + err = os.WriteFile(f.Name(), []byte("2b778dd9-f5f1-6f29-b4b4-9a5fa948757a"), 0700) + assert.Nil(t, err) + + args := []string{ + "-http-addr=" + a.HTTPAddr(), + "-format=json", + f.Name(), + } + + code := cmd.Run(args) + assert.Equal(t, code, 0) + assert.Empty(t, ui.ErrorWriter.String()) + output := ui.OutputWriter.String() + assert.Contains(t, output, "Bootstrap Token") + assert.Contains(t, output, structs.ACLPolicyGlobalManagementID) + assert.Contains(t, output, "2b778dd9-f5f1-6f29-b4b4-9a5fa948757a") + + var jsonOutput json.RawMessage + err = json.Unmarshal([]byte(output), &jsonOutput) + require.NoError(t, err, "token unmarshalling error") +} diff --git a/website/content/api-docs/acl/index.mdx b/website/content/api-docs/acl/index.mdx index 40ef081911..0e5393a7fd 100644 --- a/website/content/api-docs/acl/index.mdx +++ b/website/content/api-docs/acl/index.mdx @@ -18,8 +18,8 @@ the [ACL tutorial](https://learn.hashicorp.com/tutorials/consul/access-control-s This endpoint does a special one-time bootstrap of the ACL system, making the first management token if the [`acl.tokens.initial_management`](/docs/agent/config/config-files#acl_tokens_initial_management) configuration entry is not specified in the Consul server configuration and if the -cluster has not been bootstrapped previously. This is available in Consul 0.9.1 and later, -and requires all Consul servers to be upgraded in order to operate. +cluster has not been bootstrapped previously. An operator created token can be provided in the body of the request to +bootstrap the cluster if required. The provided token should be presented in a UUID format. This provides a mechanism to bootstrap ACLs without having any secrets present in Consul's configuration files. @@ -73,6 +73,23 @@ applications should ignore the `ID` field as it may be removed in a future major } ``` +### Sample Request with provided token + + +```json +{ + "BootstrapSecret": "2b778dd9-f5f1-6f29-b4b4-9a5fa948757a" +} +``` + +```shell-session +$ curl \ + --request PUT \ + --data @root-token.json \ + http://127.0.0.1:8500/v1/acl/bootstrap +``` + + You can detect if something has interfered with the ACL bootstrapping process by checking the response code. A 200 response means that the bootstrap was a success, and a 403 means that the cluster has already been bootstrapped, at which point you should diff --git a/website/content/commands/acl/bootstrap.mdx b/website/content/commands/acl/bootstrap.mdx index caa0687b38..e9c029925c 100644 --- a/website/content/commands/acl/bootstrap.mdx +++ b/website/content/commands/acl/bootstrap.mdx @@ -9,8 +9,8 @@ Command: `consul acl bootstrap` Corresponding HTTP API Endpoint: [\[PUT\] /v1/acl/bootstrap](/api-docs/acl#bootstrap-acls) -The `acl bootstrap` command will request Consul to generate a new token with unlimited privileges to use -for management purposes and output its details. This can only be done once and afterwards bootstrapping +The `acl bootstrap` command generates a new token with unlimited privileges to use +for management purposes and outputs the token's details. Optionally, you can provide a Secret ID to use instead of generating a completely new token. You can create this bootstrapping token only once and afterwards bootstrapping will be disabled. If all tokens are lost and you need to bootstrap again you can follow the bootstrap [reset procedure](https://learn.hashicorp.com/consul/security-networking/acl-troubleshooting?utm_source=docs). @@ -24,7 +24,10 @@ are not supported from commands, but may be from the corresponding HTTP endpoint ## Usage -Usage: `consul acl bootstrap [options]` +Usage: `consul acl bootstrap [options] [FILE]` + +If a file is supplied (or `-` for standard input), the new token's Secret ID is read from the file. +Otherwise, Consul creates a new one. #### Command Options