Initial Rancher token secrets engine #1

Merged
benvin merged 2 commits from benvin/plugin-scaffold into main 2026-07-16 22:18:19 +10:00
27 changed files with 2542 additions and 1 deletions
+5
View File
@@ -0,0 +1,5 @@
/dist/
/vault-plugin-secrets-bind-tsig
*.out
*.test
.env
+15
View File
@@ -0,0 +1,15 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- repo: https://github.com/dnephin/pre-commit-golang
rev: v0.5.1
hooks:
- id: go-fmt
- id: go-vet
- id: go-mod-tidy
+18
View File
@@ -0,0 +1,18 @@
when:
- event: pull_request
steps:
- name: build
image: golang:1.25
commands:
- make build
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+18
View File
@@ -0,0 +1,18 @@
when:
- event: pull_request
steps:
- name: pre-commit
image: git.unkin.net/unkin/almalinux9-gobuilder:20260606
commands:
- uvx pre-commit run --all-files
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+47
View File
@@ -0,0 +1,47 @@
when:
- event: tag
steps:
- name: build
image: git.unkin.net/unkin/almalinux9-gobuilder:20260606
commands:
- make build VERSION=${CI_COMMIT_TAG}
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests: {memory: 512Mi, cpu: 1}
limits: {memory: 2Gi, cpu: 2}
- name: package
image: git.unkin.net/unkin/almalinux9-rpmbuilder:latest
commands:
- ./scripts/build-rpm.sh ${CI_COMMIT_TAG}
depends_on: [build]
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests: {memory: 512Mi, cpu: 1}
limits: {memory: 2Gi, cpu: 2}
- name: upload
image: git.unkin.net/unkin/almalinux9-base:20260606
commands:
- |
HOST="https://artifactapi.k8s.syd1.au.unkin.net"
REPO="rpm-internal"
for rpm in dist/*.rpm; do
FILE=$$(basename "$$rpm")
code=$$(curl -s -o /dev/null -w '%{http_code}' "$$HOST/api/v2/remotes/$$REPO/files/Packages/$$FILE" || true)
if [ "$$code" = "200" ]; then echo "$$FILE exists; skipping"; continue; fi
echo "Uploading $$FILE (probe $$code)"
curl -f -X PUT "$$HOST/api/v2/remotes/$$REPO/files/$$FILE" -H "Content-Type: application/x-rpm" --data-binary @"$$rpm"
done
depends_on: [package]
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests: {memory: 128Mi, cpu: 100m}
limits: {memory: 512Mi, cpu: 500m}
+33
View File
@@ -0,0 +1,33 @@
when:
- event: pull_request
steps:
- name: lint
image: golang:1.25
commands:
- make lint
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
- name: test
image: golang:1.25
commands:
- make test
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+77
View File
@@ -0,0 +1,77 @@
.PHONY: build install test lint fmt clean tidy rpm rpm-package patch minor major check-go e2e e2e-vault e2e-openbao e2e-up e2e-down
BINARY := vault-plugin-secrets-rancher
PKG := ./cmd/vault-plugin-secrets-rancher
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "0.0.0-dev")
OS ?= $(shell go env GOOS)
ARCH ?= $(shell go env GOARCH)
PLUGIN_DIR ?= ./dist
GO_VERSION_REQUIRED := 1.25
GO_VERSION_ACTUAL := $(shell go version | sed 's/go version go\([0-9]*\.[0-9]*\).*/\1/')
check-go:
@if [ "$$(printf '%s\n%s' "$(GO_VERSION_REQUIRED)" "$(GO_VERSION_ACTUAL)" | sort -V | head -1)" != "$(GO_VERSION_REQUIRED)" ]; then \
echo "ERROR: Go >= $(GO_VERSION_REQUIRED) required, found $(GO_VERSION_ACTUAL)"; exit 1; \
fi
build: check-go tidy
CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) go build -ldflags="-s -w -X main.version=$(VERSION)" -o $(PLUGIN_DIR)/$(BINARY) $(PKG)
install: build
@echo "Built $(PLUGIN_DIR)/$(BINARY) (register it with: vault plugin register -sha256=<sha> secret $(BINARY))"
test: check-go
go test -race -count=1 ./...
lint: check-go
go vet ./...
fmt: check-go
gofmt -w .
tidy:
go mod tidy
clean:
rm -rf $(PLUGIN_DIR)
rpm: build rpm-package
rpm-package:
./scripts/build-rpm.sh $(VERSION)
# End-to-end tests bring up a mock companion API plus Vault and OpenBao in
# Docker and drive the full lifecycle against each with the same plugin binary.
e2e:
./scripts/e2e.sh
e2e-vault:
ENGINES=vault ./scripts/e2e.sh
e2e-openbao:
ENGINES=openbao ./scripts/e2e.sh
e2e-up:
docker compose -f test/docker-compose.yml up -d --build
e2e-down:
docker compose -f test/docker-compose.yml down -v
_LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1)
_BASE := $(if $(_LATEST),$(_LATEST),v0.0.0)
_MAJ := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f1)
_MIN := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f2)
_PAT := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f3)
patch:
@NEW=v$(_MAJ).$(_MIN).$(shell expr $(_PAT) + 1); \
git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW
minor:
@NEW=v$(_MAJ).$(shell expr $(_MIN) + 1).0; \
git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW
major:
@NEW=v$(shell expr $(_MAJ) + 1).0.0; \
git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW
+63 -1
View File
@@ -1,3 +1,65 @@
# vault-plugin-secrets-rancher
HashiCorp Vault / OpenBao secrets engine for Rancher API tokens (seeded root rotation + dynamic scoped creds via tokens.ext.cattle.io)
A Vault / OpenBao secrets engine that manages **Rancher API tokens** through
Rancher's public `tokens.ext.cattle.io` API.
## Why
Rancher caps API-token lifetimes (commonly 90 days via
`auth-token-max-ttl-minutes`). A long-lived automation token therefore expires
and breaks things. This engine solves that two ways:
- **Seeded root rotation** — you seed a *service account* with a Rancher token.
The engine rotates it on a schedule (default: every 45 days, minting a fresh
90-day token) using the current token to create its replacement, so the
credential never lapses. Rancher only lets you mint tokens for *your own*
user, which is exactly what rotation needs.
- **Dynamic, scoped tokens** — a *role* mints short-lived tokens on demand, each
bound to a Vault lease and deleted from Rancher on revoke.
### Scope caveat
Rancher tokens inherit the **creating user's RBAC**; the only per-token
restrictions are **cluster scope** (`cluster_name`) and **TTL**. For genuine
least-privilege, create a purpose-built Rancher user per privilege level, seed a
service account with each, and point roles at the right one.
## Paths
| Path | Description |
|------|-------------|
| `config` | Rancher URL + TLS settings. |
| `service-accounts/<name>` | Seed a Rancher token; set `token_ttl` / `rotation_period`. Auto-rotated. |
| `service-accounts/<name>/rotate` | Force an immediate rotation. |
| `roles/<name>` | Mint policy: `service_account`, `cluster_name`, `ttl`, `max_ttl`, `description`. |
| `creds/<role>` | Read to mint a short-lived, lease-bound token. |
## Usage
```sh
vault secrets enable -path=rancher vault-plugin-secrets-rancher
vault write rancher/config rancher_url=https://rancher.example.com ca_cert=@rancher-ca.pem
# Seed a service account (rotate every 45d, 90d token TTL are the defaults).
vault write rancher/service-accounts/admin token="token-abcde:xxxxx"
# A role that mints 1h tokens scoped to one cluster.
vault write rancher/roles/ci service_account=admin \
cluster_name=c-m-abc123 ttl=1h max_ttl=8h
# Mint one.
vault read rancher/creds/ci
```
## Development
```sh
make build # build the plugin binary into ./dist
make test # unit tests
make e2e # full lifecycle vs mock Rancher on Vault + OpenBao (Docker)
make rpm # build Vault + OpenBao RPMs via nfpm
```
Releases are tag-driven (`make patch|minor|major`): a Woodpecker pipeline builds
the RPMs and uploads them to the internal artifactapi yum repo.
+173
View File
@@ -0,0 +1,173 @@
// Package rancher implements a Vault / OpenBao secrets engine that manages
// Rancher API tokens through the public tokens.ext.cattle.io API.
//
// The engine is seeded with a long-lived Rancher admin (or service-account)
// token. Because Rancher caps token TTLs (commonly 90 days), the engine rotates
// each seeded token well before expiry: on a schedule it uses the current token
// to mint a fresh one for the same user and swaps it in, so the credential never
// lapses. Roles then mint short-lived, cluster-scoped tokens on demand, each
// bound to a Vault lease and deleted from Rancher on revocation.
//
// Rancher only lets a token be created for the caller's own user, so a minted
// token inherits the seeding user's RBAC. To get least-privilege tokens, seed a
// separate service-account per privilege level and point roles at it.
package rancher
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"time"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
)
// errBackendNotConfigured is returned when a credential is requested before the
// Rancher connection has been configured.
var errBackendNotConfigured = errors.New("rancher backend not configured; write config first")
type rancherBackend struct {
*framework.Backend
lock sync.RWMutex
}
// Factory returns a configured Rancher secrets backend.
func Factory(ctx context.Context, conf *logical.BackendConfig) (logical.Backend, error) {
b := backend()
if err := b.Setup(ctx, conf); err != nil {
return nil, err
}
return b, nil
}
func backend() *rancherBackend {
b := &rancherBackend{}
b.Backend = &framework.Backend{
Help: strings.TrimSpace(backendHelp),
BackendType: logical.TypeLogical,
PathsSpecial: &logical.Paths{
SealWrapStorage: []string{configStoragePath},
},
Paths: framework.PathAppend(
[]*framework.Path{
pathConfig(b),
pathServiceAccount(b),
pathServiceAccountsList(b),
pathServiceAccountRotate(b),
pathRole(b),
pathRolesList(b),
pathCredentials(b),
},
),
Secrets: []*framework.Secret{
b.rancherTokenSecret(),
},
// PeriodicFunc runs on the active node roughly once a minute; it drives
// the automatic root-token rotation.
PeriodicFunc: b.periodicRotate,
}
return b
}
// clientFor builds a Rancher client from the global config, authenticated with
// the given bearer token (a service account's current token).
func (b *rancherBackend) clientFor(ctx context.Context, s logical.Storage, bearer string) (*rancherClient, error) {
config, err := getConfig(ctx, s)
if err != nil {
return nil, err
}
if config == nil {
return nil, errBackendNotConfigured
}
return newClient(config, bearer)
}
// periodicRotate rotates every service-account token whose age has reached its
// rotation_period. It never blocks the engine: individual failures are logged
// and retried on the next tick.
func (b *rancherBackend) periodicRotate(ctx context.Context, req *logical.Request) error {
b.lock.Lock()
defer b.lock.Unlock()
names, err := req.Storage.List(ctx, serviceAccountStoragePrefix)
if err != nil {
return err
}
now := time.Now().UTC()
for _, name := range names {
sa, err := b.getServiceAccount(ctx, req.Storage, name)
if err != nil || sa == nil {
continue
}
if !sa.due(now) {
continue
}
if err := b.rotateServiceAccount(ctx, req.Storage, name, sa); err != nil {
b.Logger().Error("rancher: service-account rotation failed", "service_account", name, "error", err)
continue
}
b.Logger().Info("rancher: rotated service-account token", "service_account", name)
}
return nil
}
// rotateServiceAccount mints a fresh token for the service-account user with the
// current token, persists it, then best-effort deletes the previous token. The
// caller holds b.lock.
func (b *rancherBackend) rotateServiceAccount(ctx context.Context, s logical.Storage, name string, sa *serviceAccount) error {
if sa.Token == "" {
return fmt.Errorf("service account %q has no token to rotate with", name)
}
client, err := b.clientFor(ctx, s, sa.Token)
if err != nil {
return err
}
value, tokenName, err := client.MintToken(ctx, mintRequest{
GenerateName: fmt.Sprintf("vault-%s-", name),
Description: fmt.Sprintf("vault-managed root token for service-account %q", name),
TTL: sa.TokenTTL,
})
if err != nil {
return fmt.Errorf("minting replacement token: %w", err)
}
old := sa.TokenName
sa.Token = value
sa.TokenName = tokenName
sa.LastRotated = time.Now().UTC()
if err := setJSON(ctx, s, serviceAccountStoragePrefix+name, sa); err != nil {
// The new token is live but unstored; deleting it keeps Rancher tidy and
// leaves the old token (still stored) in charge for the next tick.
_ = client.DeleteToken(ctx, tokenName)
return fmt.Errorf("persisting rotated token: %w", err)
}
// Retire the previous token. The new token authenticates the delete.
if old != "" && old != tokenName {
newClient, cerr := b.clientFor(ctx, s, value)
if cerr == nil {
if derr := newClient.DeleteToken(ctx, old); derr != nil {
b.Logger().Warn("rancher: could not delete superseded token", "service_account", name, "token", old, "error", derr)
}
}
}
return nil
}
const backendHelp = `
The rancher secrets engine manages Rancher API tokens via the public
tokens.ext.cattle.io API.
Seed a service account with a long-lived Rancher token; the engine rotates it
before Rancher's TTL cap expires so it never lapses. Roles mint short-lived,
optionally cluster-scoped tokens bound to a Vault lease and deleted on
revocation. A minted token inherits the seeding user's RBAC, so seed one service
account per privilege level for least-privilege scoping.
`
+211
View File
@@ -0,0 +1,211 @@
package rancher
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/hashicorp/vault/sdk/logical"
)
// fakeRancher is an in-memory ext.cattle.io Token API for tests. A bearer is
// valid if it is the seed token or any token this server minted, so rotation
// chains work exactly as in production.
type fakeRancher struct {
mu sync.Mutex
byName map[string]string // metadata.name -> clusterName
valid map[string]string // bearer -> metadata.name
minted int
}
func newFakeRancher(seed string) *fakeRancher {
// "seed" is the resource name of the seed token, registered so a rotation
// that knows token_name can delete it.
return &fakeRancher{
byName: map[string]string{"seed": ""},
valid: map[string]string{seed: "seed"},
}
}
func (f *fakeRancher) server(t *testing.T) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
bearer := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
f.mu.Lock()
defer f.mu.Unlock()
if _, ok := f.valid[bearer]; !ok {
w.WriteHeader(http.StatusUnauthorized)
return
}
name := strings.Trim(strings.TrimPrefix(r.URL.Path, tokensAPIPath), "/")
switch {
case r.Method == http.MethodPost && name == "":
var in token
_ = json.NewDecoder(r.Body).Decode(&in)
f.minted++
newName := in.Metadata.GenerateName + "abcd"
value := "token-" + newName
f.byName[newName] = in.Spec.ClusterName
f.valid[value] = newName
out := token{Metadata: tokenMetadata{Name: newName}, Status: tokenStatus{Value: value}}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(out)
case r.Method == http.MethodGet && name != "":
if _, ok := f.byName[name]; !ok {
w.WriteHeader(http.StatusNotFound)
return
}
_ = json.NewEncoder(w).Encode(token{Metadata: tokenMetadata{Name: name}})
case r.Method == http.MethodDelete && name != "":
if _, ok := f.byName[name]; !ok {
w.WriteHeader(http.StatusNotFound)
return
}
delete(f.byName, name)
for v, n := range f.valid {
if n == name {
delete(f.valid, v)
}
}
w.WriteHeader(http.StatusOK)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
}
func newTestBackend(t *testing.T) (*rancherBackend, logical.Storage) {
t.Helper()
config := logical.TestBackendConfig()
config.StorageView = &logical.InmemStorage{}
b, err := Factory(context.Background(), config)
if err != nil {
t.Fatalf("Factory: %v", err)
}
return b.(*rancherBackend), config.StorageView
}
func req(t *testing.T, b *rancherBackend, s logical.Storage, op logical.Operation, path string, data map[string]interface{}) *logical.Response {
t.Helper()
resp, err := b.HandleRequest(context.Background(), &logical.Request{
Operation: op,
Path: path,
Data: data,
Storage: s,
})
if err != nil {
t.Fatalf("%s %s: %v", op, path, err)
}
if resp != nil && resp.IsError() {
t.Fatalf("%s %s: %v", op, path, resp.Error())
}
return resp
}
func TestLifecycle(t *testing.T) {
fake := newFakeRancher("seed-token")
srv := fake.server(t)
defer srv.Close()
b, s := newTestBackend(t)
req(t, b, s, logical.CreateOperation, "config", map[string]interface{}{
"rancher_url": srv.URL,
})
// Seed a service account.
req(t, b, s, logical.CreateOperation, "service-accounts/admin", map[string]interface{}{
"token": "seed-token",
"token_name": "seed",
"token_ttl": "2160h",
"rotation_period": "1080h",
})
// Rotate: a new token replaces the seed and the old bearer is retired.
rot := req(t, b, s, logical.UpdateOperation, "service-accounts/admin/rotate", nil)
newName, _ := rot.Data["token_name"].(string)
if newName == "" {
t.Fatal("rotate returned no token_name")
}
fake.mu.Lock()
if _, ok := fake.valid["seed-token"]; ok {
t.Error("seed token should be retired after rotation")
}
fake.mu.Unlock()
// Role + dynamic creds.
req(t, b, s, logical.CreateOperation, "roles/ci", map[string]interface{}{
"service_account": "admin",
"cluster_name": "c-m-abc123",
"ttl": "1h",
"max_ttl": "24h",
})
creds := req(t, b, s, logical.ReadOperation, "creds/ci", nil)
if creds.Secret == nil {
t.Fatal("creds returned no secret")
}
tokenName, _ := creds.Data["token_name"].(string)
if tokenName == "" || creds.Data["token"].(string) == "" {
t.Fatal("creds missing token/token_name")
}
if creds.Data["cluster_name"].(string) != "c-m-abc123" {
t.Errorf("cluster_name = %q, want c-m-abc123", creds.Data["cluster_name"])
}
fake.mu.Lock()
if _, ok := fake.byName[tokenName]; !ok {
t.Error("minted token not present in rancher")
}
fake.mu.Unlock()
// Revoke deletes the token from rancher.
if _, err := b.HandleRequest(context.Background(), &logical.Request{
Operation: logical.RevokeOperation,
Secret: creds.Secret,
Storage: s,
}); err != nil {
t.Fatalf("revoke: %v", err)
}
fake.mu.Lock()
if _, ok := fake.byName[tokenName]; ok {
t.Error("token still present after revoke")
}
fake.mu.Unlock()
}
func TestRoleRequiresServiceAccount(t *testing.T) {
b, s := newTestBackend(t)
req(t, b, s, logical.CreateOperation, "config", map[string]interface{}{"rancher_url": "https://rancher.example.com"})
resp, err := b.HandleRequest(context.Background(), &logical.Request{
Operation: logical.CreateOperation,
Path: "roles/x",
Data: map[string]interface{}{"service_account": "missing"},
Storage: s,
})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if resp == nil || !resp.IsError() {
t.Fatal("expected error for role referencing missing service account")
}
}
func TestServiceAccountRejectsBadRotationPeriod(t *testing.T) {
b, s := newTestBackend(t)
resp, err := b.HandleRequest(context.Background(), &logical.Request{
Operation: logical.CreateOperation,
Path: "service-accounts/bad",
Data: map[string]interface{}{"token": "x", "token_ttl": "10h", "rotation_period": "20h"},
Storage: s,
})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if resp == nil || !resp.IsError() {
t.Fatal("expected error when rotation_period >= token_ttl")
}
}
+205
View File
@@ -0,0 +1,205 @@
package rancher
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
)
const defaultHTTPTimeout = 30 * time.Second
// tokensAPIPath is the ext.cattle.io Token collection endpoint on the Rancher
// aggregated API server. Token is a cluster-scoped resource, so there is no
// namespace segment.
const tokensAPIPath = "/apis/ext.cattle.io/v1/tokens"
// rancherClient talks to the Rancher aggregated Kubernetes API, specifically the
// public tokens.ext.cattle.io resource. It authenticates with a Rancher API
// token (bearer). Every token it mints belongs to the user that owns the bearer
// token — the ext API forbids creating a Token for a different spec.userID — so
// the seeded service-account user's RBAC is what a minted token inherits.
type rancherClient struct {
baseURL string
token string
httpClient *http.Client
}
// token is the ext.cattle.io/v1 Token resource, trimmed to the fields the
// plugin reads or writes.
type token struct {
APIVersion string `json:"apiVersion,omitempty"`
Kind string `json:"kind,omitempty"`
Metadata tokenMetadata `json:"metadata,omitempty"`
Spec tokenSpec `json:"spec,omitempty"`
Status tokenStatus `json:"status,omitempty"`
}
type tokenMetadata struct {
Name string `json:"name,omitempty"`
GenerateName string `json:"generateName,omitempty"`
}
type tokenSpec struct {
Description string `json:"description,omitempty"`
// TTL is the token lifetime in milliseconds. Rancher clamps it to
// auth-token-max-ttl-minutes.
TTL int64 `json:"ttl,omitempty"`
// ClusterName scopes the token to a single downstream cluster. Empty means
// the token is valid against the Rancher server (full user scope).
ClusterName string `json:"clusterName,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
}
type tokenStatus struct {
// Value is the usable token string. It is returned only in the creation
// response and never again.
Value string `json:"value,omitempty"`
}
// mintRequest describes a token to create.
type mintRequest struct {
// GenerateName is the metadata.generateName prefix; Rancher assigns a unique
// metadata.name from it.
GenerateName string
Description string
TTL time.Duration
ClusterName string
}
func newClient(cfg *rancherConfig, bearer string) (*rancherClient, error) {
if cfg == nil {
return nil, errors.New("rancher client configuration is nil")
}
if cfg.RancherURL == "" {
return nil, errors.New("rancher_url is required")
}
timeout := defaultHTTPTimeout
if cfg.RequestTimeoutSeconds > 0 {
timeout = time.Duration(cfg.RequestTimeoutSeconds) * time.Second
}
tlsConfig := &tls.Config{InsecureSkipVerify: cfg.TLSSkipVerify} //nolint:gosec // opt-in via config
if cfg.CACert != "" {
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM([]byte(cfg.CACert)) {
return nil, errors.New("ca_cert is not a valid PEM certificate")
}
tlsConfig.RootCAs = pool
}
return &rancherClient{
baseURL: strings.TrimRight(cfg.RancherURL, "/"),
token: bearer,
httpClient: &http.Client{
Timeout: timeout,
Transport: &http.Transport{TLSClientConfig: tlsConfig},
},
}, nil
}
// MintToken creates a new ext.cattle.io Token for the bearer user and returns
// its usable value and resource name. The value is only ever available here.
func (c *rancherClient) MintToken(ctx context.Context, req mintRequest) (value, name string, err error) {
body := token{
APIVersion: "ext.cattle.io/v1",
Kind: "Token",
Metadata: tokenMetadata{GenerateName: req.GenerateName},
Spec: tokenSpec{
Description: req.Description,
TTL: req.TTL.Milliseconds(),
ClusterName: req.ClusterName,
},
}
var out token
if err := c.do(ctx, http.MethodPost, tokensAPIPath, body, &out); err != nil {
return "", "", err
}
if out.Status.Value == "" {
return "", "", errors.New("rancher returned an empty token value")
}
return out.Status.Value, out.Metadata.Name, nil
}
// DeleteToken removes a Token by its resource name. A missing token is success.
func (c *rancherClient) DeleteToken(ctx context.Context, name string) error {
if name == "" {
return nil
}
return c.do(ctx, http.MethodDelete, tokensAPIPath+"/"+name, nil, nil)
}
// GetToken reads a Token by resource name (used to verify existence in tests /
// health checks). Returns nil if the token does not exist.
func (c *rancherClient) GetToken(ctx context.Context, name string) (*token, error) {
var out token
err := c.do(ctx, http.MethodGet, tokensAPIPath+"/"+name, nil, &out)
if err != nil {
if errors.Is(err, errNotFound) {
return nil, nil
}
return nil, err
}
return &out, nil
}
// errNotFound flags a 404 so callers can treat absence as non-fatal.
var errNotFound = errors.New("not found")
func (c *rancherClient) do(ctx context.Context, method, path string, payload, out interface{}) error {
var body io.Reader
if payload != nil {
raw, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("encoding request body: %w", err)
}
body = bytes.NewReader(raw)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
if err != nil {
return fmt.Errorf("building request: %w", err)
}
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("calling rancher %s %s: %w", method, path, err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode == http.StatusNotFound {
if method == http.MethodDelete {
return nil
}
return errNotFound
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("rancher %s %s returned %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(respBody)))
}
if out == nil {
return nil
}
if err := json.Unmarshal(respBody, out); err != nil {
return fmt.Errorf("decoding rancher response: %w", err)
}
return nil
}
+34
View File
@@ -0,0 +1,34 @@
package main
import (
"os"
hclog "github.com/hashicorp/go-hclog"
"github.com/hashicorp/vault/api"
"github.com/hashicorp/vault/sdk/plugin"
rancher "git.unkin.net/unkin/vault-plugin-secrets-rancher"
)
func main() {
apiClientMeta := &api.PluginAPIClientMeta{}
flags := apiClientMeta.FlagSet()
if err := flags.Parse(os.Args[1:]); err != nil {
logger := hclog.New(&hclog.LoggerOptions{})
logger.Error("failed to parse flags", "error", err)
os.Exit(1)
}
tlsConfig := apiClientMeta.GetTLSConfig()
tlsProviderFunc := api.VaultPluginTLSProvider(tlsConfig)
err := plugin.ServeMultiplex(&plugin.ServeOpts{
BackendFactoryFunc: rancher.Factory,
TLSProviderFunc: tlsProviderFunc,
})
if err != nil {
logger := hclog.New(&hclog.LoggerOptions{})
logger.Error("plugin shutting down", "error", err)
os.Exit(1)
}
}
+90
View File
@@ -0,0 +1,90 @@
module git.unkin.net/unkin/vault-plugin-secrets-rancher
go 1.25.0
require (
github.com/hashicorp/go-hclog v1.6.3
github.com/hashicorp/go-uuid v1.0.3
github.com/hashicorp/vault/api v1.15.0
github.com/hashicorp/vault/sdk v0.14.0
)
require (
github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/armon/go-metrics v0.4.1 // indirect
github.com/armon/go-radix v1.0.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/docker v26.1.5+incompatible // indirect
github.com/docker/go-connections v0.4.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/evanphx/json-patch/v5 v5.6.0 // indirect
github.com/fatih/color v1.16.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
github.com/hashicorp/go-kms-wrapping/entropy/v2 v2.0.0 // indirect
github.com/hashicorp/go-kms-wrapping/v2 v2.0.8 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-plugin v1.6.1 // indirect
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
github.com/hashicorp/go-rootcerts v1.0.2 // indirect
github.com/hashicorp/go-secure-stdlib/mlock v0.1.2 // indirect
github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8 // indirect
github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.0 // indirect
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
github.com/hashicorp/go-sockaddr v1.0.6 // indirect
github.com/hashicorp/go-version v1.6.0 // indirect
github.com/hashicorp/golang-lru v0.5.4 // indirect
github.com/hashicorp/hcl v1.0.1-vault-5 // indirect
github.com/hashicorp/yamux v0.1.1 // indirect
github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/oklog/run v1.1.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.0-rc2.0.20221005185240-3a7f492d3f1b // indirect
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 // indirect
github.com/pierrec/lz4 v2.6.1+incompatible // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect
github.com/sasha-s/go-deadlock v0.2.0 // indirect
github.com/stretchr/testify v1.11.1 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.41.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
google.golang.org/grpc v1.79.2 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+340
View File
@@ -0,0 +1,340 @@
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA=
github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4=
github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI=
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA=
github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v26.1.5+incompatible h1:NEAxTwEjxV6VbBMBoGG3zPqbiJosIApZjxlbrG9q3/g=
github.com/docker/docker v26.1.5+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/evanphx/json-patch/v5 v5.6.0 h1:b91NhWfaz02IuVxO9faSllyAtNXHMPkC5J8sJCLunww=
github.com/evanphx/json-patch/v5 v5.6.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.0 h1:+cqqvzZV87b4adx/5ayVOaYZ2CrvM4ejQvUdBzPPUss=
github.com/frankban/quicktest v1.14.0/go.mod h1:NeW+ay9A/U67EYXNFA1nPE8e/tnQv/09mUdL/ijj8og=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc=
github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-kms-wrapping/entropy/v2 v2.0.0 h1:pSjQfW3vPtrOTcasTUKgCTQT7OGPPTTMVRrOfU6FJD8=
github.com/hashicorp/go-kms-wrapping/entropy/v2 v2.0.0/go.mod h1:xvb32K2keAc+R8DSFG2IwDcydK9DBQE+fGA5fsw6hSk=
github.com/hashicorp/go-kms-wrapping/v2 v2.0.8 h1:9Q2lu1YbbmiAgvYZ7Pr31RdlVonUpX+mmDL7Z7qTA2U=
github.com/hashicorp/go-kms-wrapping/v2 v2.0.8/go.mod h1:qTCjxGig/kjuj3hk1z8pOUrzbse/GxB1tGfbrq8tGJg=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-plugin v1.6.1 h1:P7MR2UP6gNKGPp+y7EZw2kOiq4IR9WiqLvp0XOsVdwI=
github.com/hashicorp/go-plugin v1.6.1/go.mod h1:XPHFku2tFo3o3QKFgSYo+cghcUhw1NA1hZyMK0PWAw0=
github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs=
github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc=
github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
github.com/hashicorp/go-secure-stdlib/mlock v0.1.2 h1:p4AKXPPS24tO8Wc8i1gLvSKdmkiSY5xuju57czJ/IJQ=
github.com/hashicorp/go-secure-stdlib/mlock v0.1.2/go.mod h1:zq93CJChV6L9QTfGKtfBxKqD7BqqXx5O04A/ns2p5+I=
github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8 h1:iBt4Ew4XEGLfh6/bPk4rSYmuZJGizr6/x/AEizP0CQc=
github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8/go.mod h1:aiJI+PIApBRQG7FZTEBx5GiiX+HbOHilUdNxUZi4eV0=
github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.0 h1:7Yran48kl6X7jfUg3sfYDrFot1gD3LvzdC3oPu5l/qo=
github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.0/go.mod h1:9WJFu7L3d+Z4ViZmwUf+6/73/Uy7YMY1NXrB9wdElYE=
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts=
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4=
github.com/hashicorp/go-sockaddr v1.0.6 h1:RSG8rKU28VTUTvEKghe5gIhIQpv8evvNpnDEyqO4u9I=
github.com/hashicorp/go-sockaddr v1.0.6/go.mod h1:uoUUmtwU7n9Dv3O4SNLeFvg0SxQ3lyjsj6+CCykpaxI=
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek=
github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/hashicorp/hcl v1.0.1-vault-5 h1:kI3hhbbyzr4dldA8UdTb7ZlVVlI2DACdCfz31RPDgJM=
github.com/hashicorp/hcl v1.0.1-vault-5/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM=
github.com/hashicorp/vault/api v1.15.0 h1:O24FYQCWwhwKnF7CuSqP30S51rTV7vz1iACXE/pj5DA=
github.com/hashicorp/vault/api v1.15.0/go.mod h1:+5YTO09JGn0u+b6ySD/LLVf8WkJCPLAL2Vkmrn2+CM8=
github.com/hashicorp/vault/sdk v0.14.0 h1:8vagjlpLurkFTnKT9aFSGs4U1XnK2IFytnWSxgFrDo0=
github.com/hashicorp/vault/sdk v0.14.0/go.mod h1:3hnGK5yjx3CW2hFyk+Dw1jDgKxdBvUvjyxMHhq0oUFc=
github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE=
github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c=
github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo=
github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531 h1:hgVxRoDDPtQE68PT4LFvNlPz2nBKd3OMlGKIQ69OmR4=
github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531/go.mod h1:fqTUQpVYBvhCNIsMXGl2GE9q6z94DIP6NtFKXCSTVbg=
github.com/joshlf/testutil v0.0.0-20170608050642-b5d8aa79d93d h1:J8tJzRyiddAFF65YVgxli+TyWBi0f79Sld6rJP6CBcY=
github.com/joshlf/testutil v0.0.0-20170608050642-b5d8aa79d93d/go.mod h1:b+Q3v8Yrg5o15d71PSUraUzYb+jWl6wQMSBXSGS/hv0=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU=
github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0-rc2.0.20221005185240-3a7f492d3f1b h1:YWuSjZCQAPM8UUBLkYUk1e+rZcvWHJmFb6i6rM44Xs8=
github.com/opencontainers/image-spec v1.1.0-rc2.0.20221005185240-3a7f492d3f1b/go.mod h1:3OVijpioIKYWTqjiG0zfF6wvoJ4fAXGbjdZuI2NgsRQ=
github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY=
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 h1:q2e307iGHPdTGp0hoxKjt1H5pDo6utceo3dQVK3I5XQ=
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5/go.mod h1:jvVRKCrJTQWu0XVbaOlby/2lO20uSCHEMzzplHXte1o=
github.com/pierrec/lz4 v2.6.1+incompatible h1:9UY3+iC23yxF0UfGaYrGplQ+79Rg+h/q9FV9ix19jjM=
github.com/pierrec/lz4 v2.6.1+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
github.com/sasha-s/go-deadlock v0.2.0 h1:lMqc+fUb7RrFS3gQLtoQsJ7/6TV/pAIFvBsqX73DK8Y=
github.com/sasha-s/go-deadlock v0.2.0/go.mod h1:StQn567HiB1fF2yJ44N9au7wOhrPS3iZqiDbRupzT10=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 h1:Xs2Ncz0gNihqu9iosIZ5SkBbWo5T8JhhLJFMQL1qmLI=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0/go.mod h1:vy+2G/6NvVMpwGX/NyLqcC41fxepnuKHk16E6IZUcJc=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.28.0 h1:j9+03ymgYhPKmeXGk5Zu+cIZOlVzd9Zv7QIiyItjFBU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.28.0/go.mod h1:Y5+XiUG4Emn1hTfciPzGPJaSI+RpDts6BnCIir0SLqk=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0=
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.0 h1:Ljk6PdHdOhAb5aDMWXjDLMMhph+BpztA4v1QdqEW2eY=
gotest.tools/v3 v3.5.0/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
+35
View File
@@ -0,0 +1,35 @@
---
# nfpm config for the vault-plugin-secrets-rancher RPM. Rendered through
# envsubst (see scripts/build-rpm.sh) then fed to `nfpm pkg`. Built once per
# target server (Vault, OpenBao); PACKAGE_NAME and PACKAGE_PLUGIN_DIR vary.
name: ${PACKAGE_NAME}
version: ${PACKAGE_VERSION}
release: ${PACKAGE_RELEASE}
arch: ${PACKAGE_ARCH}
platform: ${PACKAGE_PLATFORM}
section: default
priority: extra
description: "${PACKAGE_DESCRIPTION}"
maintainer: ${PACKAGE_MAINTAINER}
homepage: ${PACKAGE_HOMEPAGE}
license: ${PACKAGE_LICENSE}
disable_globbing: false
replaces:
- ${PACKAGE_NAME}
provides:
- ${PACKAGE_NAME}
contents:
- src: dist/vault-plugin-secrets-rancher
dst: ${PACKAGE_PLUGIN_DIR}/vault-plugin-secrets-rancher
file_info:
mode: 0755
owner: root
group: root
scripts:
preinstall: ${PACKAGE_PREINSTALL}
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
# Ensure the plugin directory exists before the binary is laid down.
# Rendered per flavour via envsubst (see scripts/build-rpm.sh).
mkdir -p ${PACKAGE_PLUGIN_DIR}
+147
View File
@@ -0,0 +1,147 @@
package rancher
import (
"context"
"errors"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
)
const configStoragePath = "config"
// rancherConfig is the connection to the Rancher server. Per-user tokens live on
// service-account entries, not here — this is only how to reach Rancher.
type rancherConfig struct {
RancherURL string `json:"rancher_url"`
CACert string `json:"ca_cert"`
TLSSkipVerify bool `json:"tls_skip_verify"`
RequestTimeoutSeconds int `json:"request_timeout_seconds"`
}
func pathConfig(b *rancherBackend) *framework.Path {
return &framework.Path{
Pattern: "config",
DisplayAttrs: &framework.DisplayAttributes{
OperationPrefix: "rancher",
OperationSuffix: "config",
},
Fields: map[string]*framework.FieldSchema{
"rancher_url": {
Type: framework.TypeString,
Description: "Base URL of the Rancher server, e.g. https://rancher.example.com.",
Required: true,
},
"ca_cert": {
Type: framework.TypeString,
Description: "PEM CA certificate that signed the Rancher server's TLS certificate.",
},
"tls_skip_verify": {
Type: framework.TypeBool,
Description: "Skip TLS verification of the Rancher server (not recommended).",
Default: false,
},
"request_timeout_seconds": {
Type: framework.TypeInt,
Description: "HTTP timeout in seconds for calls to Rancher (default 30).",
Default: 30,
},
},
Operations: map[logical.Operation]framework.OperationHandler{
logical.ReadOperation: &framework.PathOperation{Callback: b.pathConfigRead},
logical.CreateOperation: &framework.PathOperation{Callback: b.pathConfigWrite},
logical.UpdateOperation: &framework.PathOperation{Callback: b.pathConfigWrite},
logical.DeleteOperation: &framework.PathOperation{Callback: b.pathConfigDelete},
},
ExistenceCheck: b.pathConfigExistenceCheck,
HelpSynopsis: "Configure the connection to the Rancher server.",
HelpDescription: "Configure the URL and TLS settings the backend uses to reach Rancher. Per-user tokens are configured on service-accounts/<name>.",
}
}
func (b *rancherBackend) pathConfigExistenceCheck(ctx context.Context, req *logical.Request, _ *framework.FieldData) (bool, error) {
config, err := getConfig(ctx, req.Storage)
if err != nil {
return false, err
}
return config != nil, nil
}
func (b *rancherBackend) pathConfigRead(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) {
config, err := getConfig(ctx, req.Storage)
if err != nil {
return nil, err
}
if config == nil {
return nil, nil
}
return &logical.Response{
Data: map[string]interface{}{
"rancher_url": config.RancherURL,
"tls_skip_verify": config.TLSSkipVerify,
"request_timeout_seconds": config.RequestTimeoutSeconds,
},
}, nil
}
func (b *rancherBackend) pathConfigWrite(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
config, err := getConfig(ctx, req.Storage)
if err != nil {
return nil, err
}
if config == nil {
if req.Operation == logical.UpdateOperation {
return nil, errors.New("config not found during update operation")
}
config = &rancherConfig{}
}
if v, ok := data.GetOk("rancher_url"); ok {
config.RancherURL = v.(string)
}
if v, ok := data.GetOk("ca_cert"); ok {
config.CACert = v.(string)
}
if v, ok := data.GetOk("tls_skip_verify"); ok {
config.TLSSkipVerify = v.(bool)
}
if v, ok := data.GetOk("request_timeout_seconds"); ok {
config.RequestTimeoutSeconds = v.(int)
} else if req.Operation == logical.CreateOperation {
config.RequestTimeoutSeconds = data.Get("request_timeout_seconds").(int)
}
if config.RancherURL == "" {
return logical.ErrorResponse("rancher_url is required"), nil
}
return nil, setJSON(ctx, req.Storage, configStoragePath, config)
}
func (b *rancherBackend) pathConfigDelete(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) {
return nil, req.Storage.Delete(ctx, configStoragePath)
}
func getConfig(ctx context.Context, s logical.Storage) (*rancherConfig, error) {
entry, err := s.Get(ctx, configStoragePath)
if err != nil {
return nil, err
}
if entry == nil {
return nil, nil
}
config := &rancherConfig{}
if err := entry.DecodeJSON(config); err != nil {
return nil, err
}
return config, nil
}
// setJSON stores a value as a JSON storage entry.
func setJSON(ctx context.Context, s logical.Storage, key string, value interface{}) error {
entry, err := logical.StorageEntryJSON(key, value)
if err != nil {
return err
}
return s.Put(ctx, entry)
}
+113
View File
@@ -0,0 +1,113 @@
package rancher
import (
"context"
"fmt"
"time"
"github.com/hashicorp/go-uuid"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
)
func pathCredentials(b *rancherBackend) *framework.Path {
return &framework.Path{
Pattern: "creds/" + framework.GenericNameRegex("name"),
DisplayAttrs: &framework.DisplayAttributes{
OperationPrefix: "rancher",
OperationSuffix: "credentials",
},
Fields: map[string]*framework.FieldSchema{
"name": {
Type: framework.TypeLowerCaseString,
Description: "Name of the role to mint a token for.",
Required: true,
},
},
Operations: map[logical.Operation]framework.OperationHandler{
logical.ReadOperation: &framework.PathOperation{Callback: b.pathCredentialsRead},
logical.UpdateOperation: &framework.PathOperation{Callback: b.pathCredentialsRead},
},
HelpSynopsis: "Mint a short-lived Rancher token from a role.",
HelpDescription: "Reading this path mints a new, lease-bound Rancher API token via the role's service account; the token is deleted from Rancher when the lease is revoked.",
}
}
func (b *rancherBackend) pathCredentialsRead(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
roleName := data.Get("name").(string)
role, err := b.getRole(ctx, req.Storage, roleName)
if err != nil {
return nil, err
}
if role == nil {
return logical.ErrorResponse("role %q does not exist", roleName), nil
}
sa, err := b.getServiceAccount(ctx, req.Storage, role.ServiceAccount)
if err != nil {
return nil, err
}
if sa == nil {
return logical.ErrorResponse("service account %q referenced by role %q does not exist", role.ServiceAccount, roleName), nil
}
client, err := b.clientFor(ctx, req.Storage, sa.Token)
if err != nil {
return nil, err
}
ttl, maxTTL := b.resolveTTLs(role.TTL, role.MaxTTL)
suffix, err := uuid.GenerateUUID()
if err != nil {
return nil, fmt.Errorf("generating token name suffix: %w", err)
}
description := role.Description
if description == "" {
description = fmt.Sprintf("vault dynamic token (role %q)", roleName)
}
value, tokenName, err := client.MintToken(ctx, mintRequest{
GenerateName: fmt.Sprintf("vault-%s-%s-", roleName, suffix[:8]),
Description: description,
TTL: ttl,
ClusterName: role.ClusterName,
})
if err != nil {
return nil, fmt.Errorf("minting rancher token: %w", err)
}
internal := map[string]interface{}{
"token_name": tokenName,
"service_account": role.ServiceAccount,
}
external := map[string]interface{}{
"token": value,
"token_name": tokenName,
"cluster_name": role.ClusterName,
}
resp := b.Secret(rancherTokenType).Response(external, internal)
resp.Secret.TTL = ttl
resp.Secret.MaxTTL = maxTTL
resp.Secret.Renewable = true
return resp, nil
}
// resolveTTLs clamps a role's TTL/MaxTTL against the mount and system limits.
func (b *rancherBackend) resolveTTLs(roleTTL, roleMaxTTL time.Duration) (ttl, maxTTL time.Duration) {
sysMaxTTL := b.System().MaxLeaseTTL()
maxTTL = roleMaxTTL
if maxTTL <= 0 || maxTTL > sysMaxTTL {
maxTTL = sysMaxTTL
}
ttl = roleTTL
if ttl <= 0 {
ttl = b.System().DefaultLeaseTTL()
}
if ttl > maxTTL {
ttl = maxTTL
}
return ttl, maxTTL
}
+190
View File
@@ -0,0 +1,190 @@
package rancher
import (
"context"
"errors"
"time"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
)
const roleStoragePrefix = "role/"
// rancherRole mints short-lived Rancher tokens from a service account. Each read
// of creds/<name> produces a unique, lease-bound token.
type rancherRole struct {
// ServiceAccount is the seeded service account whose token mints the creds,
// and whose RBAC the minted token inherits.
ServiceAccount string `json:"service_account"`
// ClusterName scopes minted tokens to a single downstream cluster (empty =
// full Rancher-server scope).
ClusterName string `json:"cluster_name"`
// Description is applied to each minted token (helps auditing in Rancher).
Description string `json:"description"`
TTL time.Duration `json:"ttl"`
MaxTTL time.Duration `json:"max_ttl"`
}
func pathRole(b *rancherBackend) *framework.Path {
return &framework.Path{
Pattern: "roles/" + framework.GenericNameRegex("name"),
DisplayAttrs: &framework.DisplayAttributes{
OperationPrefix: "rancher",
OperationSuffix: "role",
},
Fields: map[string]*framework.FieldSchema{
"name": {
Type: framework.TypeLowerCaseString,
Description: "Name of the role.",
Required: true,
},
"service_account": {
Type: framework.TypeString,
Description: "Service account (seeded token) used to mint credentials for this role. Its user's RBAC is inherited by minted tokens.",
Required: true,
},
"cluster_name": {
Type: framework.TypeString,
Description: "Downstream cluster the minted tokens are scoped to (empty = full Rancher-server scope).",
},
"description": {
Type: framework.TypeString,
Description: "Description applied to each minted Rancher token.",
},
"ttl": {
Type: framework.TypeDurationSecond,
Description: "Default lease TTL for tokens minted from this role.",
},
"max_ttl": {
Type: framework.TypeDurationSecond,
Description: "Maximum lease TTL for tokens minted from this role.",
},
},
Operations: map[logical.Operation]framework.OperationHandler{
logical.ReadOperation: &framework.PathOperation{Callback: b.pathRoleRead},
logical.CreateOperation: &framework.PathOperation{Callback: b.pathRoleWrite},
logical.UpdateOperation: &framework.PathOperation{Callback: b.pathRoleWrite},
logical.DeleteOperation: &framework.PathOperation{Callback: b.pathRoleDelete},
},
ExistenceCheck: b.pathRoleExistenceCheck,
HelpSynopsis: "Manage roles that mint short-lived Rancher tokens.",
HelpDescription: "Each read of creds/<name> mints a unique, lease-bound Rancher token via the role's service account.",
}
}
func pathRolesList(b *rancherBackend) *framework.Path {
return &framework.Path{
Pattern: "roles/?$",
DisplayAttrs: &framework.DisplayAttributes{
OperationPrefix: "rancher",
OperationSuffix: "roles",
},
Operations: map[logical.Operation]framework.OperationHandler{
logical.ListOperation: &framework.PathOperation{Callback: b.pathRolesList},
},
HelpSynopsis: "List roles.",
HelpDescription: "List the token-minting roles configured on this backend.",
}
}
func (b *rancherBackend) pathRoleExistenceCheck(ctx context.Context, req *logical.Request, data *framework.FieldData) (bool, error) {
role, err := b.getRole(ctx, req.Storage, data.Get("name").(string))
if err != nil {
return false, err
}
return role != nil, nil
}
func (b *rancherBackend) pathRoleRead(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
role, err := b.getRole(ctx, req.Storage, data.Get("name").(string))
if err != nil {
return nil, err
}
if role == nil {
return nil, nil
}
return &logical.Response{
Data: map[string]interface{}{
"service_account": role.ServiceAccount,
"cluster_name": role.ClusterName,
"description": role.Description,
"ttl": int64(role.TTL.Seconds()),
"max_ttl": int64(role.MaxTTL.Seconds()),
},
}, nil
}
func (b *rancherBackend) pathRoleWrite(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
name := data.Get("name").(string)
role, err := b.getRole(ctx, req.Storage, name)
if err != nil {
return nil, err
}
if role == nil {
role = &rancherRole{}
}
if v, ok := data.GetOk("service_account"); ok {
role.ServiceAccount = v.(string)
}
if v, ok := data.GetOk("cluster_name"); ok {
role.ClusterName = v.(string)
}
if v, ok := data.GetOk("description"); ok {
role.Description = v.(string)
}
if v, ok := data.GetOk("ttl"); ok {
role.TTL = time.Duration(v.(int)) * time.Second
}
if v, ok := data.GetOk("max_ttl"); ok {
role.MaxTTL = time.Duration(v.(int)) * time.Second
}
if role.ServiceAccount == "" {
return logical.ErrorResponse("service_account is required"), nil
}
if role.MaxTTL > 0 && role.TTL > role.MaxTTL {
return logical.ErrorResponse("ttl must not exceed max_ttl"), nil
}
// Fail fast if the referenced service account does not exist.
sa, err := b.getServiceAccount(ctx, req.Storage, role.ServiceAccount)
if err != nil {
return nil, err
}
if sa == nil {
return logical.ErrorResponse("service_account %q does not exist", role.ServiceAccount), nil
}
return nil, setJSON(ctx, req.Storage, roleStoragePrefix+name, role)
}
func (b *rancherBackend) pathRoleDelete(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
return nil, req.Storage.Delete(ctx, roleStoragePrefix+data.Get("name").(string))
}
func (b *rancherBackend) pathRolesList(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) {
entries, err := req.Storage.List(ctx, roleStoragePrefix)
if err != nil {
return nil, err
}
return logical.ListResponse(entries), nil
}
func (b *rancherBackend) getRole(ctx context.Context, s logical.Storage, name string) (*rancherRole, error) {
if name == "" {
return nil, errors.New("missing role name")
}
entry, err := s.Get(ctx, roleStoragePrefix+name)
if err != nil {
return nil, err
}
if entry == nil {
return nil, nil
}
role := &rancherRole{}
if err := entry.DecodeJSON(role); err != nil {
return nil, err
}
return role, nil
}
+254
View File
@@ -0,0 +1,254 @@
package rancher
import (
"context"
"errors"
"fmt"
"time"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
)
const serviceAccountStoragePrefix = "service-account/"
// defaults for the seeded-token rotation schedule.
const (
defaultTokenTTL = 90 * 24 * time.Hour // Rancher's common auth-token-max-ttl.
defaultRotationPeriod = 45 * 24 * time.Hour // rotate at half-life, well before expiry.
)
// serviceAccount is a seeded Rancher user token that the engine keeps rotated.
// Roles reference a service account and mint short-lived tokens with its token,
// so minted tokens inherit that user's RBAC.
type serviceAccount struct {
// Token is the current live Rancher token. Write-only from the API.
Token string `json:"token"`
// TokenName is the ext.cattle.io Token resource name, so the superseded
// token can be deleted after a rotation.
TokenName string `json:"token_name"`
// TokenTTL is the lifetime requested for each rotated replacement token.
TokenTTL time.Duration `json:"token_ttl"`
// RotationPeriod is how long a token is used before it is rotated. Keep it
// comfortably below TokenTTL so rotation always beats expiry.
RotationPeriod time.Duration `json:"rotation_period"`
// LastRotated is when the current token was issued (or seeded).
LastRotated time.Time `json:"last_rotated"`
}
// due reports whether the token is old enough to rotate.
func (s *serviceAccount) due(now time.Time) bool {
if s.RotationPeriod <= 0 {
return false
}
return now.Sub(s.LastRotated) >= s.RotationPeriod
}
func pathServiceAccount(b *rancherBackend) *framework.Path {
return &framework.Path{
Pattern: "service-accounts/" + framework.GenericNameRegex("name"),
DisplayAttrs: &framework.DisplayAttributes{
OperationPrefix: "rancher",
OperationSuffix: "service-account",
},
Fields: map[string]*framework.FieldSchema{
"name": {
Type: framework.TypeLowerCaseString,
Description: "Name of the service account (a seeded, auto-rotated Rancher token).",
Required: true,
},
"token": {
Type: framework.TypeString,
Description: "Rancher API token to seed this service account with. Write-only; the engine rotates it from here on.",
DisplayAttrs: &framework.DisplayAttributes{
Name: "Seed Token",
Sensitive: true,
},
},
"token_name": {
Type: framework.TypeString,
Description: "ext.cattle.io Token resource name (metadata.name) of the seed token, so the engine can delete it after the first rotation. Optional but recommended.",
},
"token_ttl": {
Type: framework.TypeDurationSecond,
Description: "Lifetime requested for each rotated replacement token (default 90d). Must not exceed Rancher's auth-token-max-ttl-minutes.",
Default: int(defaultTokenTTL.Seconds()),
},
"rotation_period": {
Type: framework.TypeDurationSecond,
Description: "How long a token is used before it is rotated (default 45d). Keep it below token_ttl.",
Default: int(defaultRotationPeriod.Seconds()),
},
},
Operations: map[logical.Operation]framework.OperationHandler{
logical.ReadOperation: &framework.PathOperation{Callback: b.pathServiceAccountRead},
logical.CreateOperation: &framework.PathOperation{Callback: b.pathServiceAccountWrite},
logical.UpdateOperation: &framework.PathOperation{Callback: b.pathServiceAccountWrite},
logical.DeleteOperation: &framework.PathOperation{Callback: b.pathServiceAccountDelete},
},
ExistenceCheck: b.pathServiceAccountExistenceCheck,
HelpSynopsis: "Manage seeded, auto-rotated Rancher service-account tokens.",
HelpDescription: "Each service account holds a Rancher token the engine rotates before Rancher's TTL cap. Roles reference a service account to mint short-lived tokens with its RBAC.",
}
}
func pathServiceAccountsList(b *rancherBackend) *framework.Path {
return &framework.Path{
Pattern: "service-accounts/?$",
DisplayAttrs: &framework.DisplayAttributes{
OperationPrefix: "rancher",
OperationSuffix: "service-accounts",
},
Operations: map[logical.Operation]framework.OperationHandler{
logical.ListOperation: &framework.PathOperation{Callback: b.pathServiceAccountsList},
},
HelpSynopsis: "List service accounts.",
HelpDescription: "List the seeded Rancher service accounts configured on this backend.",
}
}
func pathServiceAccountRotate(b *rancherBackend) *framework.Path {
return &framework.Path{
Pattern: "service-accounts/" + framework.GenericNameRegex("name") + "/rotate$",
DisplayAttrs: &framework.DisplayAttributes{
OperationPrefix: "rancher",
OperationSuffix: "service-account-rotate",
},
Fields: map[string]*framework.FieldSchema{
"name": {
Type: framework.TypeLowerCaseString,
Description: "Name of the service account to rotate now.",
Required: true,
},
},
Operations: map[logical.Operation]framework.OperationHandler{
logical.UpdateOperation: &framework.PathOperation{Callback: b.pathServiceAccountRotateNow},
},
HelpSynopsis: "Rotate a service-account token immediately.",
HelpDescription: "Forces an out-of-schedule rotation: mints a fresh token with the current one, stores it, and deletes the old token.",
}
}
func (b *rancherBackend) pathServiceAccountExistenceCheck(ctx context.Context, req *logical.Request, data *framework.FieldData) (bool, error) {
sa, err := b.getServiceAccount(ctx, req.Storage, data.Get("name").(string))
if err != nil {
return false, err
}
return sa != nil, nil
}
func (b *rancherBackend) pathServiceAccountRead(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
sa, err := b.getServiceAccount(ctx, req.Storage, data.Get("name").(string))
if err != nil {
return nil, err
}
if sa == nil {
return nil, nil
}
// The token itself is never returned.
return &logical.Response{
Data: map[string]interface{}{
"token_name": sa.TokenName,
"token_ttl": int64(sa.TokenTTL.Seconds()),
"rotation_period": int64(sa.RotationPeriod.Seconds()),
"last_rotated": sa.LastRotated.Format(time.RFC3339),
},
}, nil
}
func (b *rancherBackend) pathServiceAccountWrite(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
name := data.Get("name").(string)
sa, err := b.getServiceAccount(ctx, req.Storage, name)
if err != nil {
return nil, err
}
isCreate := sa == nil
if isCreate {
sa = &serviceAccount{}
}
if v, ok := data.GetOk("token"); ok {
sa.Token = v.(string)
// A freshly seeded token restarts the rotation clock.
sa.LastRotated = time.Now().UTC()
}
if v, ok := data.GetOk("token_name"); ok {
sa.TokenName = v.(string)
}
if v, ok := data.GetOk("token_ttl"); ok {
sa.TokenTTL = time.Duration(v.(int)) * time.Second
} else if isCreate {
sa.TokenTTL = defaultTokenTTL
}
if v, ok := data.GetOk("rotation_period"); ok {
sa.RotationPeriod = time.Duration(v.(int)) * time.Second
} else if isCreate {
sa.RotationPeriod = defaultRotationPeriod
}
if sa.Token == "" {
return logical.ErrorResponse("token is required to seed a service account"), nil
}
if sa.RotationPeriod > 0 && sa.TokenTTL > 0 && sa.RotationPeriod >= sa.TokenTTL {
return logical.ErrorResponse("rotation_period must be less than token_ttl so rotation beats expiry"), nil
}
if sa.LastRotated.IsZero() {
sa.LastRotated = time.Now().UTC()
}
return nil, setJSON(ctx, req.Storage, serviceAccountStoragePrefix+name, sa)
}
func (b *rancherBackend) pathServiceAccountDelete(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
return nil, req.Storage.Delete(ctx, serviceAccountStoragePrefix+data.Get("name").(string))
}
func (b *rancherBackend) pathServiceAccountsList(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) {
entries, err := req.Storage.List(ctx, serviceAccountStoragePrefix)
if err != nil {
return nil, err
}
return logical.ListResponse(entries), nil
}
func (b *rancherBackend) pathServiceAccountRotateNow(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
name := data.Get("name").(string)
b.lock.Lock()
defer b.lock.Unlock()
sa, err := b.getServiceAccount(ctx, req.Storage, name)
if err != nil {
return nil, err
}
if sa == nil {
return logical.ErrorResponse("service account %q does not exist", name), nil
}
if err := b.rotateServiceAccount(ctx, req.Storage, name, sa); err != nil {
return nil, fmt.Errorf("rotating service account %q: %w", name, err)
}
return &logical.Response{
Data: map[string]interface{}{
"token_name": sa.TokenName,
"last_rotated": sa.LastRotated.Format(time.RFC3339),
},
}, nil
}
func (b *rancherBackend) getServiceAccount(ctx context.Context, s logical.Storage, name string) (*serviceAccount, error) {
if name == "" {
return nil, errors.New("missing service account name")
}
entry, err := s.Get(ctx, serviceAccountStoragePrefix+name)
if err != nil {
return nil, err
}
if entry == nil {
return nil, nil
}
sa := &serviceAccount{}
if err := entry.DecodeJSON(sa); err != nil {
return nil, err
}
return sa, nil
}
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
#
# Package the (already built) plugin binary into RPMs with nfpm. Builds one RPM
# per target server: Vault (/opt/vault-plugins) and OpenBao (/opt/openbao-plugins).
# Usage: scripts/build-rpm.sh [version] (version defaults to $CI_COMMIT_TAG)
#
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT_DIR}"
VERSION="${1:-${CI_COMMIT_TAG:-0.0.0-dev}}"
VERSION="${VERSION#v}"
BINARY="vault-plugin-secrets-rancher"
DIST="dist"
if [ ! -f "${DIST}/${BINARY}" ]; then
echo "ERROR: ${DIST}/${BINARY} not found; run 'make build' first" >&2
exit 1
fi
export PACKAGE_VERSION="${VERSION}"
export PACKAGE_RELEASE="1"
export PACKAGE_ARCH="amd64"
export PACKAGE_PLATFORM="linux"
export PACKAGE_DESCRIPTION="Vault/OpenBao secrets engine for Rancher API tokens (tokens.ext.cattle.io)"
export PACKAGE_MAINTAINER="Ben Vincent <ben@unkin.net>"
export PACKAGE_HOMEPAGE="https://git.unkin.net/unkin/vault-plugin-secrets-rancher"
export PACKAGE_LICENSE="MIT"
build_flavor() {
export PACKAGE_NAME="$1"
export PACKAGE_PLUGIN_DIR="$2"
export PACKAGE_PREINSTALL="${DIST}/preinstall-${PACKAGE_NAME}.sh"
envsubst '${PACKAGE_PLUGIN_DIR}' < packaging/scripts/preinstall.sh.tmpl > "${PACKAGE_PREINSTALL}"
envsubst < packaging/nfpm.yaml > "${DIST}/nfpm-${PACKAGE_NAME}.yaml"
nfpm pkg --config "${DIST}/nfpm-${PACKAGE_NAME}.yaml" --target "${DIST}" --packager rpm
}
build_flavor "vault-plugin-secrets-rancher" "/opt/vault-plugins"
build_flavor "openbao-plugin-secrets-rancher" "/opt/openbao-plugins"
echo "Built:"
ls -1 "${DIST}"/*.rpm
Executable
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env bash
#
# End-to-end test for vault-plugin-secrets-rancher.
#
# Builds the plugin, brings up a mock Rancher ext.cattle.io API plus both Vault
# and OpenBao, then drives the identical lifecycle against each engine to prove
# the same binary works on both:
# configure -> service-account (seed) -> rotate -> role -> creds -> revoke.
#
# Select engines with ENGINES (default "vault openbao"), e.g. ENGINES=openbao.
#
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
COMPOSE_FILE="${ROOT_DIR}/test/docker-compose.yml"
COMPOSE="docker compose -f ${COMPOSE_FILE}"
BINARY="vault-plugin-secrets-rancher"
SEED_TOKEN="seed-token"
RANCHER_ADDR="http://127.0.0.1:8443" # mock rancher, from the host
MOUNT="rancher"
ENGINES="${ENGINES:-vault openbao}"
red() { printf '\033[31m%s\033[0m\n' "$*"; }
green() { printf '\033[32m%s\033[0m\n' "$*"; }
blue() { printf '\033[34m==> %s\033[0m\n' "$*"; }
cleanup() { blue "Tearing down containers"; ${COMPOSE} down -v >/dev/null 2>&1 || true; }
trap cleanup EXIT
fail() { red "FAIL: $*"; exit 1; }
wait_for() {
local desc="$1"; shift
local i=0
until "$@" >/dev/null 2>&1; do
i=$((i + 1))
[ "$i" -ge "${WAIT_RETRIES:-90}" ] && fail "timed out waiting for ${desc}"
sleep 2
done
green "ready: ${desc}"
}
# token_status NAME BEARER -> HTTP status of GET on the mock rancher token.
token_status() {
curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: Bearer $2" \
"${RANCHER_ADDR}/apis/ext.cattle.io/v1/tokens/$1"
}
jq_field() { python3 -c "import sys,json;print(json.load(sys.stdin)$1)"; }
run_engine() {
local engine="$1" container="$2" cli="$3"
blue "[${engine}] exercising the plugin"
ex() { ${COMPOSE} exec -T "${container}" "${cli}" "$@"; }
local sha; sha="$(sha256sum "${ROOT_DIR}/dist/${BINARY}" | awk '{print $1}')"
ex plugin register -sha256="${sha}" secret "${BINARY}" >/dev/null || true
ex secrets disable "${MOUNT}" >/dev/null 2>&1 || true
ex secrets enable -path="${MOUNT}" "${BINARY}" >/dev/null
green "[${engine}] plugin registered and mounted"
# The plugin runs inside the engine container, so it reaches rancher by name.
ex write "${MOUNT}/config" rancher_url="http://rancher:8443" tls_skip_verify=true >/dev/null
green "[${engine}] configured"
# --- service account: seed + manual rotate ---
ex write "${MOUNT}/service-accounts/admin" token="${SEED_TOKEN}" \
token_ttl=2160h rotation_period=1080h >/dev/null
local rot newname
rot="$(ex write -format=json "${MOUNT}/service-accounts/admin/rotate" -force)"
newname="$(printf '%s' "${rot}" | jq_field '["data"]["token_name"]')"
[ -n "${newname}" ] || fail "[${engine}] rotate returned no token_name"
green "[${engine}] service account rotated -> ${newname}"
# --- role + dynamic creds ---
ex write "${MOUNT}/roles/ci" service_account="admin" cluster_name="c-m-abc123" ttl=1h max_ttl=24h >/dev/null
local json name lease tok
json="$(ex read -format=json "${MOUNT}/creds/ci")"
name="$(printf '%s' "${json}" | jq_field '["data"]["token_name"]')"
lease="$(printf '%s' "${json}" | jq_field '["lease_id"]')"
tok="$(printf '%s' "${json}" | jq_field '["data"]["token"]')"
[ -n "${name}" ] || fail "[${engine}] no dynamic token returned"
[ -n "${tok}" ] || fail "[${engine}] dynamic creds returned empty token value"
# The rotated admin token (not the original seed) minted it; verify via that token.
[ "$(token_status "${name}" "${tok}")" = "200" ] || fail "[${engine}] dynamic token ${name} not present in rancher"
green "[${engine}] dynamic token ${name} issued (lease ${lease})"
# revoke -> token deleted from rancher
ex lease revoke "${lease}" >/dev/null
sleep 2
[ "$(token_status "${name}" "${SEED_TOKEN}")" = "404" ] || fail "[${engine}] revoked token still present in rancher"
green "[${engine}] revoked token removed from rancher"
green "[${engine}] PASSED"
}
blue "Building plugin for linux/amd64"
OS=linux ARCH=amd64 PLUGIN_DIR="${ROOT_DIR}/dist" make -C "${ROOT_DIR}" build
blue "Starting Docker stack (rancher + vault + openbao)"
${COMPOSE} up -d --build
wait_for "rancher" curl -fsS "${RANCHER_ADDR}/healthz"
for engine in ${ENGINES}; do
case "${engine}" in
vault) wait_for "vault" ${COMPOSE} exec -T vault vault status -address=http://127.0.0.1:8200; run_engine vault vault vault ;;
openbao) wait_for "openbao" ${COMPOSE} exec -T openbao bao status -address=http://127.0.0.1:8200; run_engine openbao openbao bao ;;
*) fail "unknown engine: ${engine}" ;;
esac
done
green "ALL END-TO-END CHECKS PASSED (${ENGINES})"
+85
View File
@@ -0,0 +1,85 @@
package rancher
import (
"context"
"errors"
"fmt"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
)
const rancherTokenType = "rancher_token"
func (b *rancherBackend) rancherTokenSecret() *framework.Secret {
return &framework.Secret{
Type: rancherTokenType,
Fields: map[string]*framework.FieldSchema{
"token": {
Type: framework.TypeString,
Description: "The Rancher API token value.",
},
"token_name": {
Type: framework.TypeString,
Description: "The ext.cattle.io Token resource name.",
},
"cluster_name": {
Type: framework.TypeString,
Description: "The cluster the token is scoped to (empty = full scope).",
},
},
Revoke: b.secretRevoke,
Renew: b.secretRenew,
}
}
// secretRevoke deletes the minted Rancher token via the ext.cattle.io API, using
// the service account that issued it.
func (b *rancherBackend) secretRevoke(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) {
tokenName, err := internalString(req.Secret.InternalData, "token_name")
if err != nil {
return nil, err
}
saName, err := internalString(req.Secret.InternalData, "service_account")
if err != nil {
return nil, err
}
sa, err := b.getServiceAccount(ctx, req.Storage, saName)
if err != nil {
return nil, err
}
if sa == nil {
// The issuing service account is gone; nothing left that could revoke
// the token here. Rancher will expire it at its own TTL.
return nil, fmt.Errorf("service account %q no longer exists; cannot revoke token %q", saName, tokenName)
}
client, err := b.clientFor(ctx, req.Storage, sa.Token)
if err != nil {
return nil, err
}
if err := client.DeleteToken(ctx, tokenName); err != nil {
return nil, fmt.Errorf("revoking rancher token %q: %w", tokenName, err)
}
return nil, nil
}
// secretRenew extends the Vault lease; the token material is unchanged. Note the
// Rancher-side token TTL is fixed at creation, so a renewed lease should stay
// within the token's original TTL (bounded by the role max_ttl).
func (b *rancherBackend) secretRenew(_ context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) {
return &logical.Response{Secret: req.Secret}, nil
}
func internalString(data map[string]interface{}, key string) (string, error) {
raw, ok := data[key]
if !ok {
return "", fmt.Errorf("secret is missing internal %s data", key)
}
s, ok := raw.(string)
if !ok {
return "", errors.New("secret internal " + key + " data is not a string")
}
return s, nil
}
+63
View File
@@ -0,0 +1,63 @@
# End-to-end test stack. A mock Rancher ext.cattle.io Token API (in-memory, no
# k8s) plus two secrets-engine hosts running the exact same plugin binary:
# HashiCorp Vault and OpenBao. Bind mounts use ":z" so they work under SELinux.
services:
rancher:
image: golang:1.25-alpine
working_dir: /src
environment:
MOCKRANCHER_ADDR: ":8443"
MOCKRANCHER_TOKEN: "seed-token"
GOFLAGS: "-mod=mod"
command: ["go", "run", "./test/mockrancher"]
volumes:
- ..:/src:ro,z
ports:
- "8443:8443"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8443/healthz"]
interval: 3s
timeout: 3s
retries: 40
vault:
image: hashicorp/vault:1.18
depends_on:
rancher:
condition: service_healthy
cap_add: [IPC_LOCK]
environment:
VAULT_DEV_ROOT_TOKEN_ID: root
VAULT_ADDR: http://127.0.0.1:8200
VAULT_TOKEN: root
command: ["server", "-dev", "-dev-listen-address=0.0.0.0:8200", "-config=/vault/vault.hcl"]
volumes:
- ../dist:/vault/plugins:ro,z
- ./vault/vault.hcl:/vault/vault.hcl:ro,z
ports: ["8200:8200"]
healthcheck:
test: ["CMD", "vault", "status", "-address=http://127.0.0.1:8200"]
interval: 3s
timeout: 3s
retries: 20
openbao:
image: openbao/openbao:latest
depends_on:
rancher:
condition: service_healthy
cap_add: [IPC_LOCK]
environment:
BAO_DEV_ROOT_TOKEN_ID: root
BAO_ADDR: http://127.0.0.1:8200
BAO_TOKEN: root
command: ["server", "-dev", "-dev-listen-address=0.0.0.0:8200", "-config=/openbao/bao.hcl"]
volumes:
- ../dist:/openbao/plugins:ro,z
- ./openbao/bao.hcl:/openbao/bao.hcl:ro,z
ports: ["8300:8200"]
healthcheck:
test: ["CMD", "bao", "status", "-address=http://127.0.0.1:8200"]
interval: 3s
timeout: 3s
retries: 20
+156
View File
@@ -0,0 +1,156 @@
// Command mockrancher is an in-memory stand-in for the Rancher aggregated API
// server's tokens.ext.cattle.io resource, used by the e2e tests. It implements
// just enough of the Token contract the plugin uses (create/get/delete) — no
// Kubernetes or real Rancher required. Not for production use.
//
// A bearer is accepted if it is the seed token (MOCKRANCHER_TOKEN) or any token
// this server previously minted; that lets a rotated token mint further tokens,
// exactly as the plugin's root-rotation relies on.
package main
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"log"
"net/http"
"os"
"strings"
"sync"
)
type tokenMeta struct {
Name string `json:"name,omitempty"`
GenerateName string `json:"generateName,omitempty"`
}
type tokenSpec struct {
Description string `json:"description,omitempty"`
TTL int64 `json:"ttl,omitempty"`
ClusterName string `json:"clusterName,omitempty"`
}
type tokenStatus struct {
Value string `json:"value,omitempty"`
}
type token struct {
APIVersion string `json:"apiVersion"`
Kind string `json:"kind"`
Metadata tokenMeta `json:"metadata"`
Spec tokenSpec `json:"spec"`
Status tokenStatus `json:"status"`
}
type store struct {
mu sync.Mutex
tokens map[string]*token // by metadata.name
valid map[string]string // bearer value -> metadata.name
}
func randHex(n int) string {
buf := make([]byte, n)
_, _ = rand.Read(buf)
return hex.EncodeToString(buf)
}
func (s *store) authName(r *http.Request) (string, bool) {
bearer := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if bearer == "" {
return "", false
}
s.mu.Lock()
defer s.mu.Unlock()
name, ok := s.valid[bearer]
return name, ok
}
const apiPrefix = "/apis/ext.cattle.io/v1/tokens"
func (s *store) handle(w http.ResponseWriter, r *http.Request) {
if _, ok := s.authName(r); !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
name := strings.Trim(strings.TrimPrefix(r.URL.Path, apiPrefix), "/")
s.mu.Lock()
defer s.mu.Unlock()
switch {
case r.Method == http.MethodPost && name == "": // create
var req token
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad body", http.StatusBadRequest)
return
}
newName := req.Metadata.Name
if newName == "" {
newName = req.Metadata.GenerateName + randHex(4)
}
value := "token-" + newName + ":" + randHex(16)
t := &token{
APIVersion: "ext.cattle.io/v1",
Kind: "Token",
Metadata: tokenMeta{Name: newName},
Spec: req.Spec,
Status: tokenStatus{Value: value},
}
s.tokens[newName] = t
s.valid[value] = newName
writeJSON(w, http.StatusCreated, t)
case r.Method == http.MethodGet && name != "": // read
t, ok := s.tokens[name]
if !ok {
http.NotFound(w, r)
return
}
// The value is only returned at creation; blank it on read.
out := *t
out.Status.Value = ""
writeJSON(w, http.StatusOK, &out)
case r.Method == http.MethodDelete && name != "": // delete
t, ok := s.tokens[name]
if !ok {
http.NotFound(w, r)
return
}
for v, n := range s.valid {
if n == name {
delete(s.valid, v)
}
}
delete(s.tokens, name)
_ = t
w.WriteHeader(http.StatusOK)
default:
http.Error(w, "not found", http.StatusNotFound)
}
}
func writeJSON(w http.ResponseWriter, code int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func main() {
addr := os.Getenv("MOCKRANCHER_ADDR")
if addr == "" {
addr = ":8443"
}
seed := os.Getenv("MOCKRANCHER_TOKEN")
if seed == "" {
seed = "seed-token"
}
s := &store{
tokens: map[string]*token{},
valid: map[string]string{seed: "seed"},
}
mux := http.NewServeMux()
mux.HandleFunc(apiPrefix, s.handle)
mux.HandleFunc(apiPrefix+"/", s.handle)
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) })
log.Printf("mock rancher ext.cattle.io API listening on %s", addr)
log.Fatal(http.ListenAndServe(addr, mux)) //nolint:gosec // test-only mock
}
+4
View File
@@ -0,0 +1,4 @@
# OpenBao is plugin-protocol compatible with Vault, so the very same plugin
# binary registers and runs here unchanged. Combined with `-dev` at runtime.
plugin_directory = "/openbao/plugins"
api_addr = "http://127.0.0.1:8200"
+4
View File
@@ -0,0 +1,4 @@
# Combined with `-dev` at runtime; supplies the plugin_directory the dev server
# would otherwise leave unset, so the plugin binary in ../dist can be registered.
plugin_directory = "/vault/plugins"
api_addr = "http://127.0.0.1:8200"