Merge pull request 'feat: add set-testing subcommand' (#4) from benvin/set-testing into main
ci/woodpecker/tag/release Pipeline was successful
ci/woodpecker/tag/release Pipeline was successful
Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
@@ -29,6 +29,25 @@ exec `node_terminus` fails safe rather than compiling an empty catalog.
|
||||
`http://encapi.encapi.svc.cluster.local` (the in-cluster service).
|
||||
- The HTTP request has a 10s timeout.
|
||||
|
||||
### Pinning a host to `testing`
|
||||
|
||||
```
|
||||
encapic set-testing <certname>
|
||||
```
|
||||
|
||||
Sets the node's environment to `testing` in encapi (requires the encapi
|
||||
oidc-auth release). A `testing` node's ENC omits `environment`, so any
|
||||
`puppet agent --environment X` is honoured; the host stays in `testing` until
|
||||
the next terragrunt-enc apply resets it.
|
||||
|
||||
It mints a JWT from `${VAULT_ADDR}/v1/identity/oidc/token/${ENCAPI_OIDC_ROLE}`
|
||||
(defaults `https://vault.service.consul:8200`, `encapi-testing`) using
|
||||
`VAULT_TOKEN` or `~/.vault-token`, trusting `VAULT_CACERT` when set, then
|
||||
`PUT`s `{"environment":"testing"}` to
|
||||
`${ENCAPI_URL}/api/v1/nodes/<certname>/environment`. `ENCAPI_URL` and
|
||||
`VAULT_ADDR` must be `https` and redirects are never followed, so neither
|
||||
the Vault token nor the JWT is sent over plain http or to another host.
|
||||
|
||||
## Behaviour (drop-in for the python ENC)
|
||||
|
||||
encapic reproduces the previous python script byte-for-byte:
|
||||
|
||||
@@ -58,8 +58,11 @@ func run(args []string, out io.Writer) error {
|
||||
_, err := fmt.Fprintf(out, "encapic %s\n", version)
|
||||
return err
|
||||
}
|
||||
if len(args) == 3 && args[1] == "set-testing" {
|
||||
return setTesting(args[2], out)
|
||||
}
|
||||
if len(args) != 2 {
|
||||
return fmt.Errorf("usage: %s <certname>", args[0])
|
||||
return fmt.Errorf("usage: %s <certname> | %s set-testing <certname>", args[0], args[0])
|
||||
}
|
||||
certname := args[1]
|
||||
|
||||
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const defaultVaultAddr = "https://vault.service.consul:8200"
|
||||
|
||||
// setTesting pins certname to the testing environment via encapi, using a
|
||||
// Vault identity OIDC token for the encapi-testing role.
|
||||
func setTesting(certname string, out io.Writer) error {
|
||||
baseURL := os.Getenv("ENCAPI_URL")
|
||||
if baseURL == "" {
|
||||
baseURL = defaultBaseURL
|
||||
}
|
||||
if err := requireHTTPS("ENCAPI_URL", baseURL); err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := vaultClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
jwt, err := mintToken(client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
u := strings.TrimRight(baseURL, "/") + "/api/v1/nodes/" + url.PathEscape(certname) + "/environment"
|
||||
req, err := http.NewRequest(http.MethodPut, u, bytes.NewBufferString(`{"environment":"testing"}`))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+jwt)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if _, err := do(client, req); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintf(out, "%s set to testing\n", certname)
|
||||
return err
|
||||
}
|
||||
|
||||
func mintToken(client *http.Client) (string, error) {
|
||||
addr := os.Getenv("VAULT_ADDR")
|
||||
if addr == "" {
|
||||
addr = defaultVaultAddr
|
||||
}
|
||||
if err := requireHTTPS("VAULT_ADDR", addr); err != nil {
|
||||
return "", err
|
||||
}
|
||||
role := os.Getenv("ENCAPI_OIDC_ROLE")
|
||||
if role == "" {
|
||||
role = "encapi-testing"
|
||||
}
|
||||
vaultToken, err := readVaultToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, strings.TrimRight(addr, "/")+"/v1/identity/oidc/token/"+url.PathEscape(role), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("X-Vault-Token", vaultToken)
|
||||
body, err := do(client, req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var resp struct {
|
||||
Data struct {
|
||||
Token string `json:"token"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return "", fmt.Errorf("decode vault token response: %w", err)
|
||||
}
|
||||
if resp.Data.Token == "" {
|
||||
return "", errors.New("vault returned no OIDC token")
|
||||
}
|
||||
return resp.Data.Token, nil
|
||||
}
|
||||
|
||||
func requireHTTPS(name, raw string) error {
|
||||
if parsed, err := url.Parse(raw); err != nil || parsed.Scheme != "https" {
|
||||
return fmt.Errorf("set-testing requires an https %s, got %q", name, raw)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readVaultToken() (string, error) {
|
||||
if t := os.Getenv("VAULT_TOKEN"); t != "" {
|
||||
return t, nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(home, ".vault-token"))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("no VAULT_TOKEN and cannot read ~/.vault-token: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(string(b)), nil
|
||||
}
|
||||
|
||||
func vaultClient() (*http.Client, error) {
|
||||
client := &http.Client{
|
||||
Timeout: httpTimeout,
|
||||
// Never follow redirects: they would re-send the vault token or JWT.
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
||||
}
|
||||
caFile := os.Getenv("VAULT_CACERT")
|
||||
if caFile == "" {
|
||||
return client, nil
|
||||
}
|
||||
pem, err := os.ReadFile(caFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read VAULT_CACERT: %w", err)
|
||||
}
|
||||
pool, err := x509.SystemCertPool()
|
||||
if err != nil {
|
||||
pool = x509.NewCertPool()
|
||||
}
|
||||
if !pool.AppendCertsFromPEM(pem) {
|
||||
return nil, fmt.Errorf("no certificates in VAULT_CACERT %s", caFile)
|
||||
}
|
||||
client.Transport = &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12},
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func do(client *http.Client, req *http.Request) ([]byte, error) {
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s %s: %w", req.Method, req.URL, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response from %s: %w", req.URL, err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("%s %s returned HTTP %d: %s",
|
||||
req.Method, req.URL, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func fakeVault(t *testing.T, status int, body string) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet || r.URL.Path != "/v1/identity/oidc/token/encapi-testing" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("X-Vault-Token") != "vt" {
|
||||
http.Error(w, `{"errors":["permission denied"]}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
_, _ = io.WriteString(w, body)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
trustServer(t, srv)
|
||||
return srv
|
||||
}
|
||||
|
||||
type seenPut struct{ path, auth, body string }
|
||||
|
||||
func fakeEncapi(t *testing.T, status int, seen *seenPut) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
if r.Method == http.MethodPut {
|
||||
*seen = seenPut{r.URL.Path, r.Header.Get("Authorization"), string(b)}
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
_, _ = io.WriteString(w, `{"error":"forbidden"}`)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
trustServer(t, srv)
|
||||
return srv
|
||||
}
|
||||
|
||||
// trustServer points VAULT_CACERT at the TLS server's certificate; all
|
||||
// httptest TLS servers share one certificate, so this trusts both fakes.
|
||||
func trustServer(t *testing.T, srv *httptest.Server) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "ca.pem")
|
||||
b := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: srv.Certificate().Raw})
|
||||
if err := os.WriteFile(path, b, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("VAULT_CACERT", path)
|
||||
}
|
||||
|
||||
func TestSetTestingSuccess(t *testing.T) {
|
||||
var seen seenPut
|
||||
t.Setenv("VAULT_ADDR", fakeVault(t, 200, `{"data":{"token":"jwt123"}}`).URL)
|
||||
t.Setenv("VAULT_TOKEN", "vt")
|
||||
t.Setenv("ENCAPI_URL", fakeEncapi(t, 200, &seen).URL)
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := run([]string{"encapic", "set-testing", "host.example"}, &out); err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
want := seenPut{"/api/v1/nodes/host.example/environment", "Bearer jwt123", `{"environment":"testing"}`}
|
||||
if seen != want {
|
||||
t.Errorf("PUT = %+v, want %+v", seen, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetTestingVaultTokenFile(t *testing.T) {
|
||||
var seen seenPut
|
||||
home := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(home, ".vault-token"), []byte("vt\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("VAULT_TOKEN", "")
|
||||
t.Setenv("VAULT_ADDR", fakeVault(t, 200, `{"data":{"token":"jwt123"}}`).URL)
|
||||
t.Setenv("ENCAPI_URL", fakeEncapi(t, 204, &seen).URL)
|
||||
|
||||
if err := run([]string{"encapic", "set-testing", "host.example"}, io.Discard); err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
if seen.auth != "Bearer jwt123" {
|
||||
t.Errorf("auth = %q", seen.auth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetTestingVaultError(t *testing.T) {
|
||||
var seen seenPut
|
||||
t.Setenv("VAULT_ADDR", fakeVault(t, 400, `{"errors":["role not found"]}`).URL)
|
||||
t.Setenv("VAULT_TOKEN", "vt")
|
||||
t.Setenv("ENCAPI_URL", fakeEncapi(t, 200, &seen).URL)
|
||||
|
||||
err := run([]string{"encapic", "set-testing", "host.example"}, io.Discard)
|
||||
if err == nil || !strings.Contains(err.Error(), "HTTP 400") || !strings.Contains(err.Error(), "role not found") {
|
||||
t.Fatalf("err = %v, want HTTP 400 with body", err)
|
||||
}
|
||||
if seen.path != "" {
|
||||
t.Errorf("encapi called despite vault failure: %+v", seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetTestingEncapiForbidden(t *testing.T) {
|
||||
var seen seenPut
|
||||
t.Setenv("VAULT_ADDR", fakeVault(t, 200, `{"data":{"token":"jwt123"}}`).URL)
|
||||
t.Setenv("VAULT_TOKEN", "vt")
|
||||
t.Setenv("ENCAPI_URL", fakeEncapi(t, 403, &seen).URL)
|
||||
|
||||
err := run([]string{"encapic", "set-testing", "host.example"}, io.Discard)
|
||||
if err == nil || !strings.Contains(err.Error(), "HTTP 403") || !strings.Contains(err.Error(), "forbidden") {
|
||||
t.Fatalf("err = %v, want HTTP 403 with body", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetTestingRefusesPlainHTTP(t *testing.T) {
|
||||
vaultHit := false
|
||||
vault := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { vaultHit = true }))
|
||||
t.Cleanup(vault.Close)
|
||||
t.Setenv("VAULT_ADDR", vault.URL)
|
||||
t.Setenv("VAULT_TOKEN", "vt")
|
||||
|
||||
for _, encapiURL := range []string{"http://encapi.example", "", "encapi.example", "://bad"} {
|
||||
t.Setenv("ENCAPI_URL", encapiURL)
|
||||
err := run([]string{"encapic", "set-testing", "host.example"}, io.Discard)
|
||||
if err == nil || !strings.Contains(err.Error(), "requires an https ENCAPI_URL") {
|
||||
t.Errorf("ENCAPI_URL=%q: err = %v, want https refusal", encapiURL, err)
|
||||
}
|
||||
}
|
||||
if vaultHit {
|
||||
t.Error("vault called despite non-https ENCAPI_URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetTestingRefusesPlainHTTPVault(t *testing.T) {
|
||||
vaultHit := false
|
||||
vault := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { vaultHit = true }))
|
||||
t.Cleanup(vault.Close)
|
||||
var seen seenPut
|
||||
t.Setenv("ENCAPI_URL", fakeEncapi(t, 200, &seen).URL)
|
||||
t.Setenv("VAULT_TOKEN", "vt")
|
||||
|
||||
for _, addr := range []string{vault.URL, "vault.example:8200", "://bad"} {
|
||||
t.Setenv("VAULT_ADDR", addr)
|
||||
err := run([]string{"encapic", "set-testing", "host.example"}, io.Discard)
|
||||
if err == nil || !strings.Contains(err.Error(), "requires an https VAULT_ADDR") {
|
||||
t.Errorf("VAULT_ADDR=%q: err = %v, want https refusal", addr, err)
|
||||
}
|
||||
}
|
||||
if vaultHit || seen.path != "" {
|
||||
t.Errorf("request sent despite non-https VAULT_ADDR: vault=%v encapi=%+v", vaultHit, seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetTestingDoesNotFollowRedirect(t *testing.T) {
|
||||
var hits []string
|
||||
encapi := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits = append(hits, r.URL.Path+" "+r.Header.Get("Authorization"))
|
||||
http.Redirect(w, r, "/elsewhere", http.StatusTemporaryRedirect)
|
||||
}))
|
||||
t.Cleanup(encapi.Close)
|
||||
t.Setenv("VAULT_ADDR", fakeVault(t, 200, `{"data":{"token":"jwt123"}}`).URL)
|
||||
t.Setenv("VAULT_TOKEN", "vt")
|
||||
t.Setenv("ENCAPI_URL", encapi.URL)
|
||||
|
||||
err := run([]string{"encapic", "set-testing", "host.example"}, io.Discard)
|
||||
if err == nil || !strings.Contains(err.Error(), "HTTP 307") {
|
||||
t.Fatalf("err = %v, want HTTP 307", err)
|
||||
}
|
||||
if len(hits) != 1 {
|
||||
t.Errorf("encapi requests = %v, want exactly one", hits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetTestingVaultNoToken(t *testing.T) {
|
||||
for _, body := range []string{`{"data":{"token":""}}`, `{"data":{}}`, `{}`} {
|
||||
var seen seenPut
|
||||
t.Setenv("VAULT_ADDR", fakeVault(t, 200, body).URL)
|
||||
t.Setenv("VAULT_TOKEN", "vt")
|
||||
t.Setenv("ENCAPI_URL", fakeEncapi(t, 200, &seen).URL)
|
||||
|
||||
err := run([]string{"encapic", "set-testing", "host.example"}, io.Discard)
|
||||
if err == nil || !strings.Contains(err.Error(), "no OIDC token") {
|
||||
t.Errorf("body %s: err = %v, want no OIDC token", body, err)
|
||||
}
|
||||
if seen.path != "" {
|
||||
t.Errorf("body %s: encapi called: %+v", body, seen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetTestingNoVaultToken(t *testing.T) {
|
||||
var seen seenPut
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("VAULT_TOKEN", "")
|
||||
t.Setenv("VAULT_ADDR", fakeVault(t, 200, `{"data":{"token":"jwt123"}}`).URL)
|
||||
t.Setenv("ENCAPI_URL", fakeEncapi(t, 200, &seen).URL)
|
||||
|
||||
err := run([]string{"encapic", "set-testing", "host.example"}, io.Discard)
|
||||
if err == nil || !strings.Contains(err.Error(), "no VAULT_TOKEN and cannot read ~/.vault-token") {
|
||||
t.Fatalf("err = %v, want missing vault token", err)
|
||||
}
|
||||
if seen.path != "" {
|
||||
t.Errorf("encapi called: %+v", seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultClientCACertUnreadable(t *testing.T) {
|
||||
t.Setenv("VAULT_CACERT", filepath.Join(t.TempDir(), "missing.pem"))
|
||||
if _, err := vaultClient(); err == nil || !strings.Contains(err.Error(), "read VAULT_CACERT") {
|
||||
t.Fatalf("err = %v, want read VAULT_CACERT", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultClientCACertNoPEM(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "ca.pem")
|
||||
if err := os.WriteFile(path, []byte("not a certificate\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("VAULT_CACERT", path)
|
||||
if _, err := vaultClient(); err == nil || !strings.Contains(err.Error(), "no certificates in VAULT_CACERT") {
|
||||
t.Fatalf("err = %v, want no certificates", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultClientCACertTrustsServer(t *testing.T) {
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
t.Setenv("VAULT_CACERT", "")
|
||||
plain, err := vaultClient()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := plain.Get(srv.URL); err == nil {
|
||||
t.Fatal("untrusted TLS server accepted without VAULT_CACERT")
|
||||
}
|
||||
|
||||
trustServer(t, srv)
|
||||
client, err := vaultClient()
|
||||
if err != nil {
|
||||
t.Fatalf("vaultClient: %v", err)
|
||||
}
|
||||
resp, err := client.Get(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("GET with VAULT_CACERT: %v", err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
Reference in New Issue
Block a user