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 // Create a muxer and add paths relevant for the lease cache layer
mux := http.NewServeMux() mux := http.NewServeMux()
quitEnabled := lnConfig.AgentAPI != nil && lnConfig.AgentAPI.EnableQuit
mux.Handle(consts.AgentPathCacheClear, leaseCache.HandleCacheClear(ctx)) mux.Handle(consts.AgentPathCacheClear, leaseCache.HandleCacheClear(ctx))
mux.Handle(consts.AgentPathQuit, c.handleQuit(quitEnabled))
mux.Handle(consts.AgentPathMetrics, c.handleMetrics()) mux.Handle(consts.AgentPathMetrics, c.handleMetrics())
mux.Handle("/", muxHandler) 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" "encoding/json"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"net"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@ -510,21 +511,31 @@ cache {
} }
listener "tcp" { listener "tcp" {
address = "127.0.0.1:8101" address = "%s"
tls_disable = true tls_disable = true
} }
listener "tcp" { listener "tcp" {
address = "127.0.0.1:8102" address = "%s"
tls_disable = true tls_disable = true
require_request_header = false require_request_header = false
} }
listener "tcp" { listener "tcp" {
address = "127.0.0.1:8103" address = "%s"
tls_disable = true tls_disable = true
require_request_header = 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) configPath := makeTempFile(t, "config.hcl", config)
defer os.Remove(configPath) defer os.Remove(configPath)
@ -563,19 +574,19 @@ listener "tcp" {
// Test against a listener configuration that omits // Test against a listener configuration that omits
// 'require_request_header', with the header missing from the request. // '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") req = agentClient.NewRequest("GET", "/v1/sys/health")
request(t, agentClient, req, 200) request(t, agentClient, req, 200)
// Test against a listener configuration that sets 'require_request_header' // Test against a listener configuration that sets 'require_request_header'
// to 'false', with the header missing from the request. // 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") req = agentClient.NewRequest("GET", "/v1/sys/health")
request(t, agentClient, req, 200) request(t, agentClient, req, 200)
// Test against a listener configuration that sets 'require_request_header' // Test against a listener configuration that sets 'require_request_header'
// to 'true', with the header missing from the request. // 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") req = agentClient.NewRequest("GET", "/v1/sys/health")
resp, err := agentClient.RawRequest(req) resp, err := agentClient.RawRequest(req)
if err == nil { if err == nil {
@ -587,7 +598,7 @@ listener "tcp" {
// Test against a listener configuration that sets 'require_request_header' // Test against a listener configuration that sets 'require_request_header'
// to 'true', with an invalid header present in the request. // 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 := agentClient.Headers()
h[consts.RequestHeaderName] = []string{"bogus"} h[consts.RequestHeaderName] = []string{"bogus"}
agentClient.SetHeaders(h) agentClient.SetHeaders(h)
@ -602,7 +613,7 @@ listener "tcp" {
// Test against a listener configuration that sets 'require_request_header' // Test against a listener configuration that sets 'require_request_header'
// to 'true', with the proper header present in the request. // 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") req = agentClient.NewRequest("GET", "/v1/sys/health")
request(t, agentClient, req, 200) request(t, agentClient, req, 200)
} }
@ -613,16 +624,17 @@ listener "tcp" {
func TestAgent_RequireAutoAuthWithForce(t *testing.T) { func TestAgent_RequireAutoAuthWithForce(t *testing.T) {
logger := logging.NewVaultLogger(hclog.Trace) logger := logging.NewVaultLogger(hclog.Trace)
// Create a config file // Create a config file
config := ` config := fmt.Sprintf(`
cache { cache {
use_auto_auth_token = "force" use_auto_auth_token = "force"
} }
listener "tcp" { listener "tcp" {
address = "127.0.0.1:8101" address = "%s"
tls_disable = true tls_disable = true
} }
` `, generateListenerAddress(t))
configPath := makeTempFile(t, "config.hcl", config) configPath := makeTempFile(t, "config.hcl", config)
defer os.Remove(configPath) defer os.Remove(configPath)
@ -1623,7 +1635,7 @@ func TestAgent_Cache_Retry(t *testing.T) {
cache { cache {
} }
` `
listenAddr := "127.0.0.1:18123" listenAddr := generateListenerAddress(t)
listenConfig := fmt.Sprintf(` listenConfig := fmt.Sprintf(`
listener "tcp" { listener "tcp" {
address = "%s" address = "%s"
@ -1861,7 +1873,7 @@ func TestAgent_TemplateConfig_ExitOnRetryFailure(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
listenAddr := "127.0.0.1:18123" listenAddr := generateListenerAddress(t)
listenConfig := fmt.Sprintf(` listenConfig := fmt.Sprintf(`
listener "tcp" { listener "tcp" {
address = "%s" address = "%s"
@ -2021,14 +2033,15 @@ func TestAgent_Metrics(t *testing.T) {
serverClient := cluster.Cores[0].Client serverClient := cluster.Cores[0].Client
// Create a config file // Create a config file
config := ` listenAddr := generateListenerAddress(t)
config := fmt.Sprintf(`
cache {} cache {}
listener "tcp" { listener "tcp" {
address = "127.0.0.1:8101" address = "%s"
tls_disable = true tls_disable = true
} }
` `, listenAddr)
configPath := makeTempFile(t, "config.hcl", config) configPath := makeTempFile(t, "config.hcl", config)
defer os.Remove(configPath) defer os.Remove(configPath)
@ -2062,7 +2075,7 @@ listener "tcp" {
}() }()
conf := api.DefaultConfig() conf := api.DefaultConfig()
conf.Address = "http://127.0.0.1:8101" conf.Address = "http://" + listenAddr
agentClient, err := api.NewClient(conf) agentClient, err := api.NewClient(conf)
if err != nil { if err != nil {
t.Fatalf("err: %s", err) t.Fatalf("err: %s", err)
@ -2082,3 +2095,133 @@ listener "tcp" {
"Points", "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 { profiling {
unauthenticated_pprof_access = true unauthenticated_pprof_access = true
} }
agent_api {
enable_quit = true
}
}`)) }`))
config := Config{ config := Config{
@ -833,6 +836,9 @@ listener "tcp" {
Profiling: configutil.ListenerProfiling{ Profiling: configutil.ListenerProfiling{
UnauthenticatedPProfAccess: true, UnauthenticatedPProfAccess: true,
}, },
AgentAPI: &configutil.AgentAPI{
EnableQuit: true,
},
CustomResponseHeaders: DefaultCustomHeaders, CustomResponseHeaders: DefaultCustomHeaders,
}, },
}, },

View File

@ -93,6 +93,8 @@ type Listener struct {
SocketUser string `hcl:"socket_user"` SocketUser string `hcl:"socket_user"`
SocketGroup string `hcl:"socket_group"` SocketGroup string `hcl:"socket_group"`
AgentAPI *AgentAPI `hcl:"agent_api"`
Telemetry ListenerTelemetry `hcl:"telemetry"` Telemetry ListenerTelemetry `hcl:"telemetry"`
Profiling ListenerProfiling `hcl:"profiling"` Profiling ListenerProfiling `hcl:"profiling"`
InFlightRequestLogging ListenerInFlightRequestLogging `hcl:"inflight_requests_logging"` InFlightRequestLogging ListenerInFlightRequestLogging `hcl:"inflight_requests_logging"`
@ -111,6 +113,11 @@ type Listener struct {
CustomResponseHeadersRaw interface{} `hcl:"custom_response_headers"` 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 { func (l *Listener) GoString() string {
return fmt.Sprintf("*%#v", *l) 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 // AgentPathMetrics is the path the the agent will use to expose its internal
// metrics. // metrics.
const AgentPathMetrics = "/agent/v1/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. and responses containing leased secrets generated off of these newly created tokens.
Please see the [Caching docs][caching] for information. 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 ## Configuration
These are the currently-available general configuration option: 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 Agent supports one or more [listener][listener_main] stanzas. In addition to
the standard listener configuration, an Agent's listener configuration also 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 - `require_request_header` `(bool: false)` - Require that all incoming HTTP
requests on this listener must have an `X-Vault-Request: true` header entry. 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 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`. `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 ### telemetry Stanza
Vault Agent supports the [telemetry][telemetry] stanza and collects various Vault Agent supports the [telemetry][telemetry] stanza and collects various
@ -334,6 +356,7 @@ template {
[persistent-cache]: /docs/agent/caching/persistent-caches [persistent-cache]: /docs/agent/caching/persistent-caches
[template]: /docs/agent/template [template]: /docs/agent/template
[template-config]: /docs/agent/template-config [template-config]: /docs/agent/template-config
[agent-api]: /docs/agent/#agent_api-stanza
[listener]: /docs/agent#listener-stanza [listener]: /docs/agent#listener-stanza
[listener_main]: /docs/configuration/listener/tcp [listener_main]: /docs/configuration/listener/tcp
[winsvc]: /docs/agent/winsvc [winsvc]: /docs/agent/winsvc