Quit agent endpoint with config (#14223)

* Add agent/v1/quit endpoint
  * Closes https://github.com/hashicorp/vault/issues/11089
* Agent quit API behind config setting
* Normalise test config whitespace
* Document config option

Co-authored-by: Rémi Lapeyre <remi.lapeyre@lenstra.fr>
Co-authored-by: Ben Ash <32777270+benashz@users.noreply.github.com>
This commit is contained in:
Tom Proctor 2022-02-25 10:29:05 +00:00 committed by GitHub
parent c2d7386be4
commit 3668275903
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 242 additions and 35 deletions

3
changelog/14223.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:improvement
agent: The `agent/v1/quit` endpoint can now be used to stop the Vault Agent remotely
```

View File

@ -715,7 +715,10 @@ func (c *AgentCommand) Run(args []string) int {
// Create a muxer and add paths relevant for the lease cache layer
mux := http.NewServeMux()
quitEnabled := lnConfig.AgentAPI != nil && lnConfig.AgentAPI.EnableQuit
mux.Handle(consts.AgentPathCacheClear, leaseCache.HandleCacheClear(ctx))
mux.Handle(consts.AgentPathQuit, c.handleQuit(quitEnabled))
mux.Handle(consts.AgentPathMetrics, c.handleMetrics())
mux.Handle("/", muxHandler)
@ -1047,3 +1050,22 @@ func (c *AgentCommand) handleMetrics() http.Handler {
}
})
}
func (c *AgentCommand) handleQuit(enabled bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !enabled {
w.WriteHeader(http.StatusNotFound)
return
}
switch r.Method {
case http.MethodPost:
default:
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
c.logger.Debug("received quit request")
close(c.ShutdownCh)
})
}

View File

@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"io/ioutil"
"net"
"net/http"
"os"
"path/filepath"
@ -510,21 +511,31 @@ cache {
}
listener "tcp" {
address = "127.0.0.1:8101"
address = "%s"
tls_disable = true
}
listener "tcp" {
address = "127.0.0.1:8102"
address = "%s"
tls_disable = true
require_request_header = false
}
listener "tcp" {
address = "127.0.0.1:8103"
address = "%s"
tls_disable = true
require_request_header = true
}
`
config = fmt.Sprintf(config, roleIDPath, secretIDPath)
listenAddr1 := generateListenerAddress(t)
listenAddr2 := generateListenerAddress(t)
listenAddr3 := generateListenerAddress(t)
config = fmt.Sprintf(
config,
roleIDPath,
secretIDPath,
listenAddr1,
listenAddr2,
listenAddr3,
)
configPath := makeTempFile(t, "config.hcl", config)
defer os.Remove(configPath)
@ -563,19 +574,19 @@ listener "tcp" {
// Test against a listener configuration that omits
// 'require_request_header', with the header missing from the request.
agentClient := newApiClient("http://127.0.0.1:8101", false)
agentClient := newApiClient("http://"+listenAddr1, false)
req = agentClient.NewRequest("GET", "/v1/sys/health")
request(t, agentClient, req, 200)
// Test against a listener configuration that sets 'require_request_header'
// to 'false', with the header missing from the request.
agentClient = newApiClient("http://127.0.0.1:8102", false)
agentClient = newApiClient("http://"+listenAddr2, false)
req = agentClient.NewRequest("GET", "/v1/sys/health")
request(t, agentClient, req, 200)
// Test against a listener configuration that sets 'require_request_header'
// to 'true', with the header missing from the request.
agentClient = newApiClient("http://127.0.0.1:8103", false)
agentClient = newApiClient("http://"+listenAddr3, false)
req = agentClient.NewRequest("GET", "/v1/sys/health")
resp, err := agentClient.RawRequest(req)
if err == nil {
@ -587,7 +598,7 @@ listener "tcp" {
// Test against a listener configuration that sets 'require_request_header'
// to 'true', with an invalid header present in the request.
agentClient = newApiClient("http://127.0.0.1:8103", false)
agentClient = newApiClient("http://"+listenAddr3, false)
h := agentClient.Headers()
h[consts.RequestHeaderName] = []string{"bogus"}
agentClient.SetHeaders(h)
@ -602,7 +613,7 @@ listener "tcp" {
// Test against a listener configuration that sets 'require_request_header'
// to 'true', with the proper header present in the request.
agentClient = newApiClient("http://127.0.0.1:8103", true)
agentClient = newApiClient("http://"+listenAddr3, true)
req = agentClient.NewRequest("GET", "/v1/sys/health")
request(t, agentClient, req, 200)
}
@ -613,16 +624,17 @@ listener "tcp" {
func TestAgent_RequireAutoAuthWithForce(t *testing.T) {
logger := logging.NewVaultLogger(hclog.Trace)
// Create a config file
config := `
config := fmt.Sprintf(`
cache {
use_auto_auth_token = "force"
}
listener "tcp" {
address = "127.0.0.1:8101"
address = "%s"
tls_disable = true
}
`
`, generateListenerAddress(t))
configPath := makeTempFile(t, "config.hcl", config)
defer os.Remove(configPath)
@ -1623,7 +1635,7 @@ func TestAgent_Cache_Retry(t *testing.T) {
cache {
}
`
listenAddr := "127.0.0.1:18123"
listenAddr := generateListenerAddress(t)
listenConfig := fmt.Sprintf(`
listener "tcp" {
address = "%s"
@ -1861,7 +1873,7 @@ func TestAgent_TemplateConfig_ExitOnRetryFailure(t *testing.T) {
t.Fatal(err)
}
listenAddr := "127.0.0.1:18123"
listenAddr := generateListenerAddress(t)
listenConfig := fmt.Sprintf(`
listener "tcp" {
address = "%s"
@ -2021,14 +2033,15 @@ func TestAgent_Metrics(t *testing.T) {
serverClient := cluster.Cores[0].Client
// Create a config file
config := `
listenAddr := generateListenerAddress(t)
config := fmt.Sprintf(`
cache {}
listener "tcp" {
address = "127.0.0.1:8101"
address = "%s"
tls_disable = true
}
`
`, listenAddr)
configPath := makeTempFile(t, "config.hcl", config)
defer os.Remove(configPath)
@ -2062,7 +2075,7 @@ listener "tcp" {
}()
conf := api.DefaultConfig()
conf.Address = "http://127.0.0.1:8101"
conf.Address = "http://" + listenAddr
agentClient, err := api.NewClient(conf)
if err != nil {
t.Fatalf("err: %s", err)
@ -2082,3 +2095,133 @@ listener "tcp" {
"Points",
})
}
func TestAgent_Quit(t *testing.T) {
//----------------------------------------------------
// Start the server and agent
//----------------------------------------------------
logger := logging.NewVaultLogger(hclog.Error)
cluster := vault.NewTestCluster(t,
&vault.CoreConfig{
Logger: logger,
CredentialBackends: map[string]logical.Factory{
"approle": credAppRole.Factory,
},
LogicalBackends: map[string]logical.Factory{
"kv": logicalKv.Factory,
},
},
&vault.TestClusterOptions{
NumCores: 1,
})
cluster.Start()
defer cluster.Cleanup()
vault.TestWaitActive(t, cluster.Cores[0].Core)
serverClient := cluster.Cores[0].Client
// Unset the environment variable so that agent picks up the right test
// cluster address
defer os.Setenv(api.EnvVaultAddress, os.Getenv(api.EnvVaultAddress))
err := os.Unsetenv(api.EnvVaultAddress)
if err != nil {
t.Fatal(err)
}
listenAddr := generateListenerAddress(t)
listenAddr2 := generateListenerAddress(t)
config := fmt.Sprintf(`
vault {
address = "%s"
tls_skip_verify = true
}
listener "tcp" {
address = "%s"
tls_disable = true
}
listener "tcp" {
address = "%s"
tls_disable = true
agent_api {
enable_quit = true
}
}
cache {}
`, serverClient.Address(), listenAddr, listenAddr2)
configPath := makeTempFile(t, "config.hcl", config)
defer os.Remove(configPath)
// Start the agent
_, cmd := testAgentCommand(t, logger)
cmd.startedCh = make(chan struct{})
wg := &sync.WaitGroup{}
wg.Add(1)
go func() {
cmd.Run([]string{"-config", configPath})
wg.Done()
}()
select {
case <-cmd.startedCh:
case <-time.After(5 * time.Second):
t.Errorf("timeout")
}
client, err := api.NewClient(api.DefaultConfig())
if err != nil {
t.Fatal(err)
}
client.SetToken(serverClient.Token())
client.SetMaxRetries(0)
err = client.SetAddress("http://" + listenAddr)
if err != nil {
t.Fatal(err)
}
// First try on listener 1 where the API should be disabled.
resp, err := client.RawRequest(client.NewRequest(http.MethodPost, "/agent/v1/quit"))
if err == nil {
t.Fatalf("expected error")
}
if resp != nil && resp.StatusCode != http.StatusNotFound {
t.Fatalf("expected %d but got: %d", http.StatusNotFound, resp.StatusCode)
}
// Now try on listener 2 where the quit API should be enabled.
err = client.SetAddress("http://" + listenAddr2)
if err != nil {
t.Fatal(err)
}
_, err = client.RawRequest(client.NewRequest(http.MethodPost, "/agent/v1/quit"))
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
select {
case <-cmd.ShutdownCh:
case <-time.After(5 * time.Second):
t.Errorf("timeout")
}
wg.Wait()
}
// Get a randomly assigned port and then free it again before returning it.
// There is still a race when trying to use it, but should work better
// than a static port.
func generateListenerAddress(t *testing.T) string {
t.Helper()
ln1, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
listenAddr := ln1.Addr().String()
ln1.Close()
return listenAddr
}

View File

@ -796,6 +796,9 @@ listener "tcp" {
profiling {
unauthenticated_pprof_access = true
}
agent_api {
enable_quit = true
}
}`))
config := Config{
@ -833,6 +836,9 @@ listener "tcp" {
Profiling: configutil.ListenerProfiling{
UnauthenticatedPProfAccess: true,
},
AgentAPI: &configutil.AgentAPI{
EnableQuit: true,
},
CustomResponseHeaders: DefaultCustomHeaders,
},
},

View File

@ -93,6 +93,8 @@ type Listener struct {
SocketUser string `hcl:"socket_user"`
SocketGroup string `hcl:"socket_group"`
AgentAPI *AgentAPI `hcl:"agent_api"`
Telemetry ListenerTelemetry `hcl:"telemetry"`
Profiling ListenerProfiling `hcl:"profiling"`
InFlightRequestLogging ListenerInFlightRequestLogging `hcl:"inflight_requests_logging"`
@ -111,6 +113,11 @@ type Listener struct {
CustomResponseHeadersRaw interface{} `hcl:"custom_response_headers"`
}
// AgentAPI allows users to select which parts of the Agent API they want enabled.
type AgentAPI struct {
EnableQuit bool `hcl:"enable_quit"`
}
func (l *Listener) GoString() string {
return fmt.Sprintf("*%#v", *l)
}

View File

@ -7,3 +7,6 @@ const AgentPathCacheClear = "/agent/v1/cache-clear"
// AgentPathMetrics is the path the the agent will use to expose its internal
// metrics.
const AgentPathMetrics = "/agent/v1/metrics"
// AgentPathQuit is the path that the agent will use to trigger stopping it.
const AgentPathQuit = "/agent/v1/quit"

View File

@ -109,6 +109,22 @@ Vault Agent allows client-side caching of responses containing newly created tok
and responses containing leased secrets generated off of these newly created tokens.
Please see the [Caching docs][caching] for information.
## API
### Quit
This endpoints triggers shutdown of the agent. By default, it is disabled, and can
be enabled per listener using the [`agent_api`][agent-api] stanza. It is recommended
to only enable this on trusted interfaces, as it does not require any authorization to use.
| Method | Path |
| :----- | :--------------- |
| `POST` | `/agent/v1/quit` |
### Cache
See the [caching](/docs/agent/caching#api) page for details on the cache API.
## Configuration
These are the currently-available general configuration option:
@ -214,7 +230,7 @@ to address in the future.
Agent supports one or more [listener][listener_main] stanzas. In addition to
the standard listener configuration, an Agent's listener configuration also
supports an additional optional entry:
supports the following:
- `require_request_header` `(bool: false)` - Require that all incoming HTTP
requests on this listener must have an `X-Vault-Request: true` header entry.
@ -222,6 +238,12 @@ supports an additional optional entry:
Request Forgery attacks. Requests on the listener that do not have the proper
`X-Vault-Request` header will fail, with a HTTP response status code of `412: Precondition Failed`.
- `agent_api` <code>([agent_api][agent-api]: <optional\>)</code> - Manages optional Agent API endpoints.
#### agent_api Stanza
- `enable_quit` `(bool: false)` - If set to `true`, the agent will enable the [quit](/docs/agent#quit) API.
### telemetry Stanza
Vault Agent supports the [telemetry][telemetry] stanza and collects various
@ -334,6 +356,7 @@ template {
[persistent-cache]: /docs/agent/caching/persistent-caches
[template]: /docs/agent/template
[template-config]: /docs/agent/template-config
[agent-api]: /docs/agent/#agent_api-stanza
[listener]: /docs/agent#listener-stanza
[listener_main]: /docs/configuration/listener/tcp
[winsvc]: /docs/agent/winsvc