Scaffold vault-plugin-secrets-arrstack engine #1

Merged
benvin merged 1 commits from benvin/scaffold-engine into main 2026-08-18 22:37:25 +10:00
25 changed files with 2657 additions and 1 deletions
+5
View File
@@ -0,0 +1,5 @@
/dist/
/vault-plugin-secrets-arrstack
*.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
+70
View File
@@ -0,0 +1,70 @@
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:
# build-rpm.sh prints "PLUGIN_SHA256 <binary> <version> <sha>" for the
# Puppet plugin registration pin.
- ./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")
# Verify the package isn't already published before uploading.
# artifactapi has no HEAD route (returns 405), so probe with GET
# against the served path (RPMs are stored under Packages/).
code=$$(curl -s -o /dev/null -w '%{http_code}' "$$HOST/api/v2/remotes/$$REPO/files/Packages/$$FILE" || true)
if [ "$$code" = "200" ]; then
echo "$$FILE already exists in $$REPO (HTTP $$code); skipping upload"
continue
fi
echo "Uploading $$FILE to $$REPO (existence probe returned $$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
+62
View File
@@ -0,0 +1,62 @@
.PHONY: build install test lint fmt clean tidy rpm rpm-package patch minor major check-go
BINARY := vault-plugin-secrets-arrstack
PKG := ./cmd/vault-plugin-secrets-arrstack
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)
# Build the plugin binary then package it into an RPM with nfpm.
rpm: build rpm-package
# Package an already-built binary into an RPM (used by CI after the build step).
rpm-package:
./scripts/build-rpm.sh $(VERSION)
_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
+144 -1
View File
@@ -1,3 +1,146 @@
# vault-plugin-secrets-arrstack # vault-plugin-secrets-arrstack
Vault/OpenBao secrets engine minting dynamic arrproxy per-user API tokens A dynamic secrets engine that mints **arrproxy machine tokens** on both
**HashiCorp Vault** and **[OpenBao](https://openbao.org)**.
[arrproxy](https://git.unkin.net/unkin/arrproxy) fronts the *arr apps
(Sonarr / Radarr / Prowlarr) behind a single OAuth-gated proxy that brokers
per-caller API tokens. Its human path derives scope from OAuth identity headers,
so a non-interactive caller cannot use it. This engine calls arrproxy's
bearer-gated **admin API** (`/api/admin/tokens`) instead, so Terraform-driven
*arr onboarding can mint and revoke machine tokens without a human in the loop.
Each minted token is:
- **scoped to a subset of apps** (`apps`: any of `sonarr`, `radarr`, `prowlarr`)
- **subject-namespaced** as `vault:arrstack:<role>` (arrproxy requires the prefix)
- **bound to a Vault lease** — revoking the lease disables the token in arrproxy
## Vault and OpenBao
OpenBao is a fork of Vault and keeps its plugin protocol compatible, so the
**same plugin binary** registers and runs on either engine unchanged — the CLI
commands below are identical apart from `vault` vs `bao`.
## How it works
The backend authenticates to arrproxy's admin API with the admin bearer token
and manages tokens through `POST /api/admin/tokens` (mint) and
`DELETE /api/admin/tokens/{id}` (revoke). Each minted token is wrapped in a
Vault lease, so Vault owns the token's lifecycle: revoke disables it, renew
extends it up to the token's fixed expiry.
```
┌────────┐ read creds/<role> ┌────────────────────────┐ POST /api/admin/tokens ┌──────────┐
│ client │ ──────────────────► │ vault + arrstack plugin │ ───────────────────────► │ arrproxy │
└────────┘ ◄── machine token ─└────────────────────────┘ ◄──── {id, token} ────── └──────────┘
```
## Usage
```sh
# 1. Enable the engine
vault secrets enable -path=arrstack vault-plugin-secrets-arrstack
# 2. Configure the connection to arrproxy's admin API
vault write arrstack/config \
base_url=https://arrstack.unkin.net \
admin_token=$ARRPROXY_ADMIN_TOKEN \
ca_cert=@traefik-external-ca.pem # optional
# 3. Define a role: which apps, what TTLs
vault write arrstack/roles/media \
apps="sonarr,radarr" \
ttl=1h \
max_ttl=24h
# 4. Mint a scoped, time-limited machine token
vault read arrstack/creds/media
# Key Value
# --- -----
# lease_id arrstack/creds/media/AbC...
# lease_duration 1h
# apps [radarr sonarr]
# id tok-...
# subject vault:arrstack:media
# token arr_...
# 5. Revoking the lease disables the token in arrproxy
vault lease revoke arrstack/creds/media/AbC...
```
### Example roles
| Role | `apps` | Use |
| ---------- | ----------------------------- | ------------------------------------ |
| `sonarr` | `sonarr` | a token that only reaches Sonarr |
| `radarr` | `radarr` | a token that only reaches Radarr |
| `prowlarr` | `prowlarr` | a token that only reaches Prowlarr |
| `all` | `sonarr,radarr,prowlarr` | a token that reaches all three apps |
```sh
vault write arrstack/roles/sonarr apps="sonarr" ttl=1h max_ttl=24h
vault write arrstack/roles/radarr apps="radarr" ttl=1h max_ttl=24h
vault write arrstack/roles/prowlarr apps="prowlarr" ttl=1h max_ttl=24h
vault write arrstack/roles/all apps="sonarr,radarr,prowlarr" ttl=1h max_ttl=24h
```
## Paths
| Path | Ops | Description |
| ---------------- | ------------------ | ------------------------------------------------- |
| `config` | read/write/delete | arrproxy connection (`base_url`, `admin_token`) |
| `roles/<name>` | read/write/delete | Constraints for minted tokens |
| `roles/` | list | List configured roles |
| `creds/<name>` | read | Mint a machine token for the role |
### Config fields
| Field | Type | Description |
| ------------------------- | -------- | ------------------------------------------------------------------- |
| `base_url` | string | arrproxy base URL, e.g. `https://arrstack.unkin.net` |
| `admin_token` | string | arrproxy admin bearer token (write-only, never returned on read) |
| `ca_cert` | string | PEM CA to verify the arrproxy TLS cert; empty uses the system store |
| `request_timeout_seconds` | int | HTTP timeout for admin API calls (default 30) |
### Role fields
| Field | Type | Description |
| --------- | -------- | ------------------------------------------------------------------ |
| `apps` | list | Non-empty subset of `sonarr`/`radarr`/`prowlarr` |
| `ttl` | duration | Default lease TTL |
| `max_ttl` | duration | Maximum lease TTL |
## TTL and renewal
At mint time the effective initial lease TTL is sent to arrproxy as
`ttl_seconds`, so the arrproxy token is given a **fixed expiry** at that point
(`expires_at` in the mint response, which the engine stores). Because that
expiry is fixed and arrproxy does not extend an already-issued token, a lease
renewal can never push the lease past the token's expiry: the renew callback
honours the role's `max_ttl` but **caps the renewed TTL at the remaining time to
the token's expiry**, and an already-expired token is not extended. For a
longer-lived token, issue a new one (or set a larger role `ttl`).
## Development
```sh
make build # build the plugin into ./dist
make test # unit tests (race-enabled)
make lint # go vet
make fmt # gofmt
make rpm # build the binary and package the Vault + OpenBao RPMs
```
### Releasing
Versioning is tag-driven; pushing a `v*` tag runs the release pipeline, which
builds the binary, packages the Vault (`/opt/vault-plugins`) and OpenBao
(`/opt/openbao-plugins`) RPMs, prints the binary `sha256` for Puppet plugin
registration, and uploads the RPMs to the artifactapi `rpm-internal` repo.
```sh
make patch # v0.1.0 -> v0.1.1
make minor # v0.1.1 -> v0.2.0
make major # v0.2.0 -> v1.0.0
```
+119
View File
@@ -0,0 +1,119 @@
package arrstack
import (
"context"
"strings"
"sync"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
)
// arrstackBackend is the Vault secrets backend that mints dynamic arrproxy
// machine tokens via arrproxy's bearer-gated admin API.
type arrstackBackend struct {
*framework.Backend
lock sync.RWMutex
client *arrproxyClient
}
// Factory returns a configured arrstack 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() *arrstackBackend {
b := &arrstackBackend{}
b.Backend = &framework.Backend{
Help: strings.TrimSpace(backendHelp),
BackendType: logical.TypeLogical,
PathsSpecial: &logical.Paths{
LocalStorage: []string{},
SealWrapStorage: []string{
configStoragePath,
},
},
Paths: framework.PathAppend(
[]*framework.Path{
pathConfig(b),
pathRole(b),
pathRolesList(b),
pathCredentials(b),
},
),
Secrets: []*framework.Secret{
b.arrstackToken(),
},
Invalidate: b.invalidate,
WALRollback: nil,
}
return b
}
// reset drops the cached arrproxy client so it is rebuilt from storage on the
// next request. Called when the config changes.
func (b *arrstackBackend) reset() {
b.lock.Lock()
defer b.lock.Unlock()
b.client = nil
}
// invalidate clears the cached client when the config is written from another
// cluster node.
func (b *arrstackBackend) invalidate(_ context.Context, key string) {
if key == configStoragePath {
b.reset()
}
}
// getClient returns a cached arrproxy client, building one from stored config if
// necessary.
func (b *arrstackBackend) getClient(ctx context.Context, s logical.Storage) (*arrproxyClient, error) {
b.lock.RLock()
if b.client != nil {
defer b.lock.RUnlock()
return b.client, nil
}
b.lock.RUnlock()
b.lock.Lock()
defer b.lock.Unlock()
if b.client != nil {
return b.client, nil
}
config, err := getConfig(ctx, s)
if err != nil {
return nil, err
}
if config == nil {
return nil, errBackendNotConfigured
}
client, err := newClient(config)
if err != nil {
return nil, err
}
b.client = client
return b.client, nil
}
const backendHelp = `
The arrstack secrets backend mints dynamic arrproxy machine tokens by calling
arrproxy's bearer-gated admin API. Each token is scoped to a subset of the *arr
apps (sonarr/radarr/prowlarr) and bound to a Vault lease: revoking the lease
disables the token in arrproxy.
After mounting this backend, configure it with the arrproxy connection details
using the "config" path, then define one or more roles that constrain the
apps and TTLs of issued tokens. Reading "creds/<role>" mints a new machine
token whose lifetime Vault manages.
`
+191
View File
@@ -0,0 +1,191 @@
package arrstack
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/hashicorp/vault/sdk/logical"
)
// getTestBackend returns a configured backend backed by in-memory storage.
func getTestBackend(t *testing.T) (*arrstackBackend, logical.Storage) {
t.Helper()
config := logical.TestBackendConfig()
config.StorageView = &logical.InmemStorage{}
config.System = logical.TestSystemView()
b, err := Factory(context.Background(), config)
if err != nil {
t.Fatalf("unexpected error creating backend: %v", err)
}
return b.(*arrstackBackend), config.StorageView
}
// mockToken is a token row held by the fake arrproxy admin server.
type mockToken struct {
Subject string
Apps []string
Label string
ExpiresAt *time.Time
Disabled bool
}
// mockArrproxy is an in-memory fake of arrproxy's admin token API. It mirrors
// the real handler's checks: bearer auth, the vault:arrstack: subject prefix,
// and a non-empty subset of the configured apps.
type mockArrproxy struct {
server *httptest.Server
mu sync.Mutex
tokens map[string]*mockToken // id -> token
counter int
adminToken string
apps map[string]bool
mintErr bool
lastRequest mintTokenRequest
}
func newMockArrproxy(t *testing.T) *mockArrproxy {
t.Helper()
m := &mockArrproxy{
tokens: make(map[string]*mockToken),
adminToken: "arrproxy-admin-secret",
apps: map[string]bool{"sonarr": true, "radarr": true, "prowlarr": true},
}
mux := http.NewServeMux()
mux.HandleFunc("POST /api/admin/tokens", m.handleMint)
mux.HandleFunc("DELETE /api/admin/tokens/{id}", m.handleRevoke)
m.server = httptest.NewServer(m.authMiddleware(mux))
t.Cleanup(m.server.Close)
return m
}
// authMiddleware fails closed to 404 when no admin token is set and 401 on a
// mismatch, matching arrproxy's adminAuth.
func (m *mockArrproxy) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if m.adminToken == "" {
http.NotFound(w, r)
return
}
if r.Header.Get("Authorization") != "Bearer "+m.adminToken {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
func (m *mockArrproxy) tokenCount() int {
m.mu.Lock()
defer m.mu.Unlock()
n := 0
for _, t := range m.tokens {
if !t.Disabled {
n++
}
}
return n
}
func (m *mockArrproxy) handleMint(w http.ResponseWriter, r *http.Request) {
m.mu.Lock()
defer m.mu.Unlock()
if m.mintErr {
http.Error(w, `{"error":"boom"}`, http.StatusInternalServerError)
return
}
var req mintTokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
m.lastRequest = req
if !strings.HasPrefix(req.Subject, "vault:arrstack:") || strings.TrimSpace(strings.TrimPrefix(req.Subject, "vault:arrstack:")) == "" {
http.Error(w, "subject must be namespaced with vault:arrstack:", http.StatusBadRequest)
return
}
if len(req.Apps) == 0 {
http.Error(w, "apps must be a non-empty subset", http.StatusBadRequest)
return
}
for _, a := range req.Apps {
if !m.apps[a] {
http.Error(w, "unknown app", http.StatusBadRequest)
return
}
}
if req.TTLSeconds < 0 {
http.Error(w, "ttl_seconds must not be negative", http.StatusBadRequest)
return
}
m.counter++
id := "tok-" + strconv.Itoa(m.counter)
var expiresAt *time.Time
if req.TTLSeconds > 0 {
exp := time.Now().UTC().Add(time.Duration(req.TTLSeconds) * time.Second)
expiresAt = &exp
}
m.tokens[id] = &mockToken{
Subject: req.Subject,
Apps: req.Apps,
Label: req.Label,
ExpiresAt: expiresAt,
}
writeJSON(w, http.StatusCreated, map[string]interface{}{
"id": id,
"token": "arr_" + id + "_plaintext",
"expires_at": expiresAt,
})
}
func (m *mockArrproxy) handleRevoke(w http.ResponseWriter, r *http.Request) {
m.mu.Lock()
defer m.mu.Unlock()
id := r.PathValue("id")
if tok, ok := m.tokens[id]; ok {
tok.Disabled = true
}
// Idempotent: a missing id still returns 204.
w.WriteHeader(http.StatusNoContent)
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
// writeTestConfig stores a config pointing at the given base URL.
func writeTestConfig(t *testing.T, b *arrstackBackend, s logical.Storage, baseURL, adminToken string) {
t.Helper()
resp, err := b.HandleRequest(context.Background(), &logical.Request{
Operation: logical.CreateOperation,
Path: "config",
Storage: s,
Data: map[string]interface{}{
"base_url": baseURL,
"admin_token": adminToken,
},
})
if err != nil || (resp != nil && resp.IsError()) {
t.Fatalf("failed to write config: err=%v resp=%v", err, resp)
}
}
+146
View File
@@ -0,0 +1,146 @@
package arrstack
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// errBackendNotConfigured is returned when an operation needs the arrproxy
// connection config but none has been written yet.
var errBackendNotConfigured = errors.New("arrstack backend not configured: write connection details to config/ first")
const defaultHTTPTimeout = 30 * time.Second
// arrproxyClient talks to arrproxy's admin API (/api/admin/tokens) using the
// admin bearer token for authentication.
type arrproxyClient struct {
baseURL string
adminToken string
httpClient *http.Client
}
func newClient(config *arrstackConfig) (*arrproxyClient, error) {
if config == nil {
return nil, errors.New("arrstack client configuration is nil")
}
if config.BaseURL == "" {
return nil, errors.New("base_url is required")
}
if config.AdminToken == "" {
return nil, errors.New("admin_token is required")
}
timeout := defaultHTTPTimeout
if config.RequestTimeoutSeconds > 0 {
timeout = time.Duration(config.RequestTimeoutSeconds) * time.Second
}
transport := http.DefaultTransport.(*http.Transport).Clone()
if config.CACert != "" {
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM([]byte(config.CACert)) {
return nil, errors.New("ca_cert is not a valid PEM certificate bundle")
}
transport.TLSClientConfig = &tls.Config{RootCAs: pool}
}
return &arrproxyClient{
baseURL: strings.TrimRight(config.BaseURL, "/"),
adminToken: config.AdminToken,
httpClient: &http.Client{Timeout: timeout, Transport: transport},
}, nil
}
// mintTokenRequest is the payload for POST /api/admin/tokens. The subject MUST
// carry the "vault:arrstack:" prefix or arrproxy rejects the request.
type mintTokenRequest struct {
Subject string `json:"subject"`
Apps []string `json:"apps"`
Label string `json:"label"`
TTLSeconds int64 `json:"ttl_seconds"`
}
// mintTokenResponse is the subset of the admin-mint response we consume. The
// plaintext token is returned exactly once. ExpiresAt is nil when the token
// has no expiry (ttl_seconds == 0).
type mintTokenResponse struct {
ID string `json:"id"`
Token string `json:"token"`
ExpiresAt *time.Time `json:"expires_at"`
}
// MintToken mints a new arrproxy machine token for the given namespaced subject.
func (c *arrproxyClient) MintToken(ctx context.Context, req mintTokenRequest) (*mintTokenResponse, error) {
var out mintTokenResponse
if err := c.do(ctx, http.MethodPost, "/api/admin/tokens", req, &out); err != nil {
return nil, err
}
if out.Token == "" {
return nil, errors.New("arrproxy returned an empty token")
}
if out.ID == "" {
return nil, errors.New("arrproxy returned an empty token id")
}
return &out, nil
}
// RevokeToken disables a token by id. arrproxy treats this as idempotent: a
// missing or already-disabled id still returns 204.
func (c *arrproxyClient) RevokeToken(ctx context.Context, id string) error {
if id == "" {
return errors.New("id is required to revoke")
}
return c.do(ctx, http.MethodDelete, "/api/admin/tokens/"+id, nil, nil)
}
// do performs an authenticated HTTP request against the arrproxy admin API and
// decodes the JSON response into out (when non-nil).
func (c *arrproxyClient) do(ctx context.Context, method, path string, payload, out interface{}) error {
var bodyReader io.Reader
if payload != nil {
raw, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("encoding request body: %w", err)
}
bodyReader = bytes.NewReader(raw)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bodyReader)
if err != nil {
return fmt.Errorf("building request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.adminToken)
req.Header.Set("Accept", "application/json")
if bodyReader != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("calling arrproxy %s %s: %w", method, path, err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("arrproxy %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 arrproxy response: %w", err)
}
return nil
}
+140
View File
@@ -0,0 +1,140 @@
package arrstack
import (
"context"
"testing"
)
func TestClient_MintToken(t *testing.T) {
m := newMockArrproxy(t)
client, err := newClient(&arrstackConfig{BaseURL: m.server.URL, AdminToken: m.adminToken})
if err != nil {
t.Fatalf("newClient: %v", err)
}
resp, err := client.MintToken(context.Background(), mintTokenRequest{
Subject: "vault:arrstack:media",
Apps: []string{"sonarr", "radarr"},
Label: "vault-media-abcd1234",
TTLSeconds: 3600,
})
if err != nil {
t.Fatalf("MintToken: %v", err)
}
if resp.Token == "" || resp.ID == "" {
t.Fatalf("expected non-empty token and id, got %+v", resp)
}
if resp.ExpiresAt == nil {
t.Fatal("expected a non-nil expires_at for a positive ttl")
}
if m.tokenCount() != 1 {
t.Fatalf("expected 1 token on server, got %d", m.tokenCount())
}
if m.lastRequest.Subject != "vault:arrstack:media" {
t.Fatalf("expected subject forwarded, got %q", m.lastRequest.Subject)
}
}
func TestClient_MintToken_RejectsBadSubject(t *testing.T) {
m := newMockArrproxy(t)
client, _ := newClient(&arrstackConfig{BaseURL: m.server.URL, AdminToken: m.adminToken})
_, err := client.MintToken(context.Background(), mintTokenRequest{
Subject: "media",
Apps: []string{"sonarr"},
TTLSeconds: 60,
})
if err == nil {
t.Fatal("expected an error when the subject lacks the vault:arrstack: prefix")
}
}
func TestClient_RevokeToken(t *testing.T) {
m := newMockArrproxy(t)
client, _ := newClient(&arrstackConfig{BaseURL: m.server.URL, AdminToken: m.adminToken})
resp, err := client.MintToken(context.Background(), mintTokenRequest{
Subject: "vault:arrstack:media",
Apps: []string{"prowlarr"},
TTLSeconds: 60,
})
if err != nil {
t.Fatalf("MintToken: %v", err)
}
if err := client.RevokeToken(context.Background(), resp.ID); err != nil {
t.Fatalf("RevokeToken: %v", err)
}
if m.tokenCount() != 0 {
t.Fatalf("expected 0 active tokens after revoke, got %d", m.tokenCount())
}
}
func TestClient_RevokeToken_Idempotent(t *testing.T) {
m := newMockArrproxy(t)
client, _ := newClient(&arrstackConfig{BaseURL: m.server.URL, AdminToken: m.adminToken})
if err := client.RevokeToken(context.Background(), "does-not-exist"); err != nil {
t.Fatalf("revoking a missing id should be idempotent, got %v", err)
}
}
func TestClient_AuthFailure(t *testing.T) {
m := newMockArrproxy(t)
client, _ := newClient(&arrstackConfig{BaseURL: m.server.URL, AdminToken: "wrong-token"})
_, err := client.MintToken(context.Background(), mintTokenRequest{
Subject: "vault:arrstack:media",
Apps: []string{"sonarr"},
TTLSeconds: 60,
})
if err == nil {
t.Fatal("expected an auth error, got nil")
}
}
func TestClient_ServerError(t *testing.T) {
m := newMockArrproxy(t)
m.mintErr = true
client, _ := newClient(&arrstackConfig{BaseURL: m.server.URL, AdminToken: m.adminToken})
_, err := client.MintToken(context.Background(), mintTokenRequest{
Subject: "vault:arrstack:media",
Apps: []string{"sonarr"},
TTLSeconds: 60,
})
if err == nil {
t.Fatal("expected a server error, got nil")
}
}
func TestNewClient_Validation(t *testing.T) {
cases := []struct {
name string
config *arrstackConfig
wantErr bool
}{
{"nil config", nil, true},
{"missing base_url", &arrstackConfig{AdminToken: "t"}, true},
{"missing admin_token", &arrstackConfig{BaseURL: "http://x"}, true},
{"invalid ca_cert", &arrstackConfig{BaseURL: "http://x", AdminToken: "t", CACert: "not-a-pem"}, true},
{"valid", &arrstackConfig{BaseURL: "http://x", AdminToken: "t"}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := newClient(tc.config)
if (err != nil) != tc.wantErr {
t.Fatalf("newClient err=%v wantErr=%v", err, tc.wantErr)
}
})
}
}
func TestNewClient_TrimsTrailingSlash(t *testing.T) {
client, err := newClient(&arrstackConfig{BaseURL: "https://arrstack.unkin.net/", AdminToken: "t"})
if err != nil {
t.Fatalf("newClient: %v", err)
}
if client.baseURL != "https://arrstack.unkin.net" {
t.Fatalf("expected trailing slash trimmed, got %q", client.baseURL)
}
}
+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"
arrstack "git.unkin.net/unkin/vault-plugin-secrets-arrstack"
)
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: arrstack.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-arrstack
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.4 // indirect
github.com/go-logr/logr v1.4.4 // 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.45.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 // indirect
go.opentelemetry.io/otel/metric v1.45.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.45.0 // indirect
go.opentelemetry.io/otel/trace v1.45.0 // indirect
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.47.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a // indirect
google.golang.org/grpc v1.82.1 // 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.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/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.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/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.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
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.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 h1:QRefszxJmfPdjXUUm3j6iDzY03mTPXMjqErFqQ67vUg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0/go.mod h1:Tiz03lTBVBrm7eWZBOidzEaYaJa8tjwGUGv6d8mlTyk=
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.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw=
go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA=
go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o=
go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA=
go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc=
go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk=
go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E=
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.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
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.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
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.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
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.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.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.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
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.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
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.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260720211330-0afa2a65878a h1:97PfJ4tCxY5C7NzzgGqQEMZmXbISdvSArNNEOoUGKBg=
google.golang.org/genproto/googleapis/api v0.0.0-20260720211330-0afa2a65878a/go.mod h1:1brfde68Npq6+WA75c1EHWPijZEG1kMus61ygPZfn4A=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a h1:qI/YMH1ep2qQtqcp00gMQyoU7mjvbhg88GJKCvfoLj0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
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=
+38
View File
@@ -0,0 +1,38 @@
---
# nfpm config for building the vault-plugin-secrets-arrstack 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 per flavour.
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}
# Install the plugin binary into the server's plugin directory. Point the
# server's plugin_directory at PACKAGE_PLUGIN_DIR to pick it up.
contents:
- src: dist/vault-plugin-secrets-arrstack
dst: ${PACKAGE_PLUGIN_DIR}/vault-plugin-secrets-arrstack
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}
+177
View File
@@ -0,0 +1,177 @@
package arrstack
import (
"context"
"errors"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
)
const configStoragePath = "config"
// arrstackConfig holds the connection details for the arrproxy admin API.
type arrstackConfig struct {
BaseURL string `json:"base_url"`
AdminToken string `json:"admin_token"`
CACert string `json:"ca_cert"`
RequestTimeoutSeconds int `json:"request_timeout_seconds"`
}
func pathConfig(b *arrstackBackend) *framework.Path {
return &framework.Path{
Pattern: "config",
DisplayAttrs: &framework.DisplayAttributes{
OperationPrefix: "arrstack",
OperationSuffix: "config",
},
Fields: map[string]*framework.FieldSchema{
"base_url": {
Type: framework.TypeString,
Description: "Base URL of the arrproxy deployment, e.g. https://arrstack.unkin.net.",
Required: true,
DisplayAttrs: &framework.DisplayAttributes{
Name: "Base URL",
},
},
"admin_token": {
Type: framework.TypeString,
Description: "arrproxy admin bearer token (ARRPROXY_ADMIN_TOKEN) used to mint and revoke machine tokens.",
Required: true,
DisplayAttrs: &framework.DisplayAttributes{
Name: "Admin Token",
Sensitive: true,
},
},
"ca_cert": {
Type: framework.TypeString,
Description: "PEM-encoded CA certificate used to verify the arrproxy TLS certificate. Empty uses the system trust store.",
DisplayAttrs: &framework.DisplayAttributes{
Name: "CA Certificate (PEM)",
},
},
"request_timeout_seconds": {
Type: framework.TypeInt,
Description: "HTTP timeout in seconds for calls to the arrproxy admin API (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 arrproxy admin API.",
HelpDescription: "Configure the base URL, admin token, and optional CA certificate the backend uses to mint arrproxy machine tokens.",
}
}
func (b *arrstackBackend) 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 *arrstackBackend) 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
}
// The admin token is deliberately not returned.
return &logical.Response{
Data: map[string]interface{}{
"base_url": config.BaseURL,
"ca_cert": config.CACert,
"request_timeout_seconds": config.RequestTimeoutSeconds,
},
}, nil
}
func (b *arrstackBackend) 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 = &arrstackConfig{}
}
if v, ok := data.GetOk("base_url"); ok {
config.BaseURL = v.(string)
}
if v, ok := data.GetOk("admin_token"); ok {
config.AdminToken = v.(string)
}
if v, ok := data.GetOk("ca_cert"); ok {
config.CACert = v.(string)
}
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.BaseURL == "" {
return logical.ErrorResponse("base_url is required"), nil
}
if config.AdminToken == "" {
return logical.ErrorResponse("admin_token is required"), nil
}
entry, err := logical.StorageEntryJSON(configStoragePath, config)
if err != nil {
return nil, err
}
if err := req.Storage.Put(ctx, entry); err != nil {
return nil, err
}
b.reset()
return nil, nil
}
func (b *arrstackBackend) pathConfigDelete(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) {
if err := req.Storage.Delete(ctx, configStoragePath); err != nil {
return nil, err
}
b.reset()
return nil, nil
}
// getConfig reads and decodes the stored arrstack config, returning nil if none
// exists.
func getConfig(ctx context.Context, s logical.Storage) (*arrstackConfig, error) {
entry, err := s.Get(ctx, configStoragePath)
if err != nil {
return nil, err
}
if entry == nil {
return nil, nil
}
config := &arrstackConfig{}
if err := entry.DecodeJSON(config); err != nil {
return nil, err
}
return config, nil
}
+95
View File
@@ -0,0 +1,95 @@
package arrstack
import (
"context"
"testing"
"github.com/hashicorp/vault/sdk/logical"
)
func TestConfig_WriteReadDelete(t *testing.T) {
b, s := getTestBackend(t)
ctx := context.Background()
resp, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.CreateOperation,
Path: "config",
Storage: s,
Data: map[string]interface{}{
"base_url": "https://arrstack.unkin.net",
"admin_token": "arrproxy-admin-secret",
"ca_cert": "",
"request_timeout_seconds": 15,
},
})
if err != nil || (resp != nil && resp.IsError()) {
t.Fatalf("write config: err=%v resp=%v", err, resp)
}
// Read must not leak the admin token.
resp, err = b.HandleRequest(ctx, &logical.Request{
Operation: logical.ReadOperation,
Path: "config",
Storage: s,
})
if err != nil || resp == nil {
t.Fatalf("read config: err=%v resp=%v", err, resp)
}
if resp.Data["base_url"] != "https://arrstack.unkin.net" {
t.Fatalf("unexpected base_url: %v", resp.Data["base_url"])
}
if resp.Data["request_timeout_seconds"] != 15 {
t.Fatalf("unexpected timeout: %v", resp.Data["request_timeout_seconds"])
}
if _, ok := resp.Data["admin_token"]; ok {
t.Fatal("admin_token must not be returned on read")
}
if _, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.DeleteOperation,
Path: "config",
Storage: s,
}); err != nil {
t.Fatalf("delete config: %v", err)
}
cfg, err := getConfig(ctx, s)
if err != nil {
t.Fatalf("getConfig: %v", err)
}
if cfg != nil {
t.Fatal("expected config to be nil after delete")
}
}
func TestConfig_RequiredFields(t *testing.T) {
b, s := getTestBackend(t)
ctx := context.Background()
resp, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.CreateOperation,
Path: "config",
Storage: s,
Data: map[string]interface{}{"base_url": "https://arrstack.unkin.net"},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp == nil || !resp.IsError() {
t.Fatal("expected an error response when admin_token is missing")
}
}
func TestConfig_DefaultTimeout(t *testing.T) {
b, s := getTestBackend(t)
ctx := context.Background()
writeTestConfig(t, b, s, "https://arrstack.unkin.net", "arrproxy-admin-secret")
cfg, err := getConfig(ctx, s)
if err != nil {
t.Fatalf("getConfig: %v", err)
}
if cfg.RequestTimeoutSeconds != 30 {
t.Fatalf("expected default timeout 30, got %d", cfg.RequestTimeoutSeconds)
}
}
+132
View File
@@ -0,0 +1,132 @@
package arrstack
import (
"context"
"fmt"
"time"
"github.com/hashicorp/go-uuid"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
)
// subjectPrefix namespaces every machine-minted subject. arrproxy's admin API
// rejects any subject that does not carry this prefix.
const subjectPrefix = "vault:arrstack:"
func pathCredentials(b *arrstackBackend) *framework.Path {
return &framework.Path{
Pattern: "creds/" + framework.GenericNameRegex("name"),
DisplayAttrs: &framework.DisplayAttributes{
OperationPrefix: "arrstack",
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 an arrproxy machine token from a role.",
HelpDescription: "Reading this path mints a new arrproxy machine token scoped to the named role's apps and TTL.",
}
}
func (b *arrstackBackend) 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
}
return b.mintToken(ctx, req, roleName, role)
}
// mintToken issues a new arrproxy machine token for the given role and wraps it
// in a Vault lease.
func (b *arrstackBackend) mintToken(ctx context.Context, req *logical.Request, roleName string, role *arrstackRole) (*logical.Response, error) {
client, err := b.getClient(ctx, req.Storage)
if err != nil {
return nil, err
}
ttl, maxTTL := b.resolveTTLs(role)
suffix, err := uuid.GenerateUUID()
if err != nil {
return nil, fmt.Errorf("generating token label suffix: %w", err)
}
label := fmt.Sprintf("vault-%s-%s", roleName, suffix[:8])
subject := subjectPrefix + roleName
minted, err := client.MintToken(ctx, mintTokenRequest{
Subject: subject,
Apps: role.Apps,
Label: label,
TTLSeconds: int64(ttl.Seconds()),
})
if err != nil {
return nil, fmt.Errorf("minting arrproxy token: %w", err)
}
// arrproxy is authoritative for the token's fixed expiry. Fall back to the
// derived expiry only if the response omits it.
expiresAt := time.Now().Add(ttl)
if minted.ExpiresAt != nil {
expiresAt = *minted.ExpiresAt
}
internal := map[string]interface{}{
"id": minted.ID,
"role": roleName,
"expires_at": expiresAt.Format(time.RFC3339),
}
external := map[string]interface{}{
"token": minted.Token,
"id": minted.ID,
"apps": role.Apps,
"subject": subject,
"expires_at": expiresAt.Format(time.RFC3339),
}
resp := b.Secret(arrstackTokenType).Response(external, internal)
resp.Secret.TTL = ttl
resp.Secret.MaxTTL = maxTTL
if ttl > 0 {
resp.Secret.Renewable = true
}
return resp, nil
}
// resolveTTLs clamps the role's TTL/MaxTTL against the mount and system limits.
func (b *arrstackBackend) resolveTTLs(role *arrstackRole) (ttl, maxTTL time.Duration) {
sysMaxTTL := b.System().MaxLeaseTTL()
maxTTL = role.MaxTTL
if maxTTL <= 0 || maxTTL > sysMaxTTL {
maxTTL = sysMaxTTL
}
ttl = role.TTL
if ttl <= 0 {
ttl = b.System().DefaultLeaseTTL()
}
if ttl > maxTTL {
ttl = maxTTL
}
return ttl, maxTTL
}
+214
View File
@@ -0,0 +1,214 @@
package arrstack
import (
"context"
"strings"
"testing"
"time"
"github.com/hashicorp/vault/sdk/logical"
)
func createRole(t *testing.T, b *arrstackBackend, s logical.Storage, name string, data map[string]interface{}) {
t.Helper()
resp, err := b.HandleRequest(context.Background(), &logical.Request{
Operation: logical.CreateOperation,
Path: "roles/" + name,
Storage: s,
Data: data,
})
if err != nil || (resp != nil && resp.IsError()) {
t.Fatalf("create role %s: err=%v resp=%v", name, err, resp)
}
}
func TestCredentials_MintAndRevoke(t *testing.T) {
b, s := getTestBackend(t)
ctx := context.Background()
m := newMockArrproxy(t)
writeTestConfig(t, b, s, m.server.URL, m.adminToken)
createRole(t, b, s, "media", map[string]interface{}{
"apps": "sonarr,radarr",
"ttl": "1h",
"max_ttl": "24h",
})
resp, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.ReadOperation,
Path: "creds/media",
Storage: s,
})
if err != nil || resp == nil {
t.Fatalf("mint creds: err=%v resp=%v", err, resp)
}
if resp.Secret == nil {
t.Fatal("expected a secret in the response")
}
token, _ := resp.Data["token"].(string)
if token == "" {
t.Fatal("expected a non-empty token in response data")
}
if resp.Data["id"].(string) == "" {
t.Fatal("expected a non-empty id in response data")
}
if resp.Secret.TTL != time.Hour {
t.Fatalf("expected TTL 1h, got %s", resp.Secret.TTL)
}
if !resp.Secret.Renewable {
t.Fatal("expected the lease to be renewable")
}
if m.tokenCount() != 1 {
t.Fatalf("expected 1 token on server, got %d", m.tokenCount())
}
// The subject carries the required prefix and the role name.
if m.lastRequest.Subject != "vault:arrstack:media" {
t.Fatalf("expected subject vault:arrstack:media, got %q", m.lastRequest.Subject)
}
// The role's apps (sorted) were forwarded.
if strings.Join(m.lastRequest.Apps, ",") != "radarr,sonarr" {
t.Fatalf("expected apps radarr,sonarr forwarded, got %v", m.lastRequest.Apps)
}
// ttl_seconds is derived from the effective initial lease TTL.
if m.lastRequest.TTLSeconds != 3600 {
t.Fatalf("expected ttl_seconds 3600, got %d", m.lastRequest.TTLSeconds)
}
// The label is prefixed for attribution.
if !strings.HasPrefix(m.lastRequest.Label, "vault-media-") {
t.Fatalf("expected label prefixed vault-media-, got %q", m.lastRequest.Label)
}
revokeReq := &logical.Request{
Operation: logical.RevokeOperation,
Path: "creds/media",
Storage: s,
Secret: resp.Secret,
}
if _, err := b.HandleRequest(ctx, revokeReq); err != nil {
t.Fatalf("revoke: %v", err)
}
if m.tokenCount() != 0 {
t.Fatalf("expected token disabled on revoke, got %d active", m.tokenCount())
}
}
func TestCredentials_UnknownRole(t *testing.T) {
b, s := getTestBackend(t)
m := newMockArrproxy(t)
writeTestConfig(t, b, s, m.server.URL, m.adminToken)
resp, err := b.HandleRequest(context.Background(), &logical.Request{
Operation: logical.ReadOperation,
Path: "creds/nope",
Storage: s,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp == nil || !resp.IsError() {
t.Fatal("expected an error response for an unknown role")
}
}
func TestCredentials_NotConfigured(t *testing.T) {
b, s := getTestBackend(t)
createRole(t, b, s, "media", map[string]interface{}{"apps": "sonarr", "ttl": "1h"})
_, err := b.HandleRequest(context.Background(), &logical.Request{
Operation: logical.ReadOperation,
Path: "creds/media",
Storage: s,
})
if err == nil {
t.Fatal("expected an error when backend is not configured")
}
}
func TestCredentials_TTLClampedToMaxTTL(t *testing.T) {
b, s := getTestBackend(t)
m := newMockArrproxy(t)
writeTestConfig(t, b, s, m.server.URL, m.adminToken)
createRole(t, b, s, "short", map[string]interface{}{"apps": "prowlarr", "max_ttl": "2h"})
resp, err := b.HandleRequest(context.Background(), &logical.Request{
Operation: logical.ReadOperation,
Path: "creds/short",
Storage: s,
})
if err != nil || resp == nil {
t.Fatalf("mint creds: err=%v resp=%v", err, resp)
}
if resp.Secret.TTL <= 0 || resp.Secret.TTL > 2*time.Hour {
t.Fatalf("expected TTL within (0, 2h], got %s", resp.Secret.TTL)
}
}
func TestCredentials_RenewCappedAtTokenExpiry(t *testing.T) {
b, s := getTestBackend(t)
ctx := context.Background()
m := newMockArrproxy(t)
writeTestConfig(t, b, s, m.server.URL, m.adminToken)
createRole(t, b, s, "media", map[string]interface{}{"apps": "sonarr", "ttl": "1h", "max_ttl": "24h"})
resp, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.ReadOperation,
Path: "creds/media",
Storage: s,
})
if err != nil || resp == nil {
t.Fatalf("mint creds: err=%v resp=%v", err, resp)
}
renewResp, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.RenewOperation,
Path: "creds/media",
Storage: s,
Secret: resp.Secret,
})
if err != nil {
t.Fatalf("renew: %v", err)
}
if renewResp == nil || renewResp.Secret == nil {
t.Fatal("expected a secret in the renew response")
}
// The token's fixed expiry is ~1h out, so the renewed lease can never
// exceed that remaining window even though max_ttl is 24h.
if renewResp.Secret.TTL <= 0 || renewResp.Secret.TTL > time.Hour {
t.Fatalf("expected renewed TTL capped within (0, 1h], got %s", renewResp.Secret.TTL)
}
}
func TestCredentials_RenewExpiredTokenNotExtended(t *testing.T) {
b, s := getTestBackend(t)
ctx := context.Background()
m := newMockArrproxy(t)
writeTestConfig(t, b, s, m.server.URL, m.adminToken)
createRole(t, b, s, "media", map[string]interface{}{"apps": "sonarr", "ttl": "1h", "max_ttl": "24h"})
resp, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.ReadOperation,
Path: "creds/media",
Storage: s,
})
if err != nil || resp == nil {
t.Fatalf("mint creds: err=%v resp=%v", err, resp)
}
// Simulate the arrproxy token having already reached its fixed expiry.
resp.Secret.InternalData["expires_at"] = time.Now().Add(-time.Minute).Format(time.RFC3339)
renewResp, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.RenewOperation,
Path: "creds/media",
Storage: s,
Secret: resp.Secret,
})
if err != nil {
t.Fatalf("renew: %v", err)
}
if renewResp.Secret.TTL != 0 {
t.Fatalf("expected an expired token not to be extended, got TTL %s", renewResp.Secret.TTL)
}
}
+228
View File
@@ -0,0 +1,228 @@
package arrstack
import (
"context"
"fmt"
"sort"
"time"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
)
const roleStoragePrefix = "role/"
// knownApps is the fixed set of *arr apps arrproxy fronts. A role's apps must be
// a non-empty subset of these.
var knownApps = map[string]bool{
"sonarr": true,
"radarr": true,
"prowlarr": true,
}
// arrstackRole constrains the machine tokens minted from the creds/<name> path.
type arrstackRole struct {
// Apps is the subset of sonarr/radarr/prowlarr a minted token may reach.
Apps []string `json:"apps"`
// TTL is the default lease duration for tokens issued from this role.
TTL time.Duration `json:"ttl"`
// MaxTTL is the maximum lease duration for tokens issued from this role.
MaxTTL time.Duration `json:"max_ttl"`
}
func (r *arrstackRole) toResponseData() map[string]interface{} {
return map[string]interface{}{
"apps": r.Apps,
"ttl": int64(r.TTL.Seconds()),
"max_ttl": int64(r.MaxTTL.Seconds()),
}
}
// validateApps returns the requested apps de-duplicated and sorted if they form
// a non-empty subset of knownApps; otherwise it returns an error.
func validateApps(requested []string) ([]string, error) {
if len(requested) == 0 {
return nil, fmt.Errorf("apps must be a non-empty subset of sonarr/radarr/prowlarr")
}
seen := map[string]bool{}
out := make([]string, 0, len(requested))
for _, a := range requested {
if !knownApps[a] {
return nil, fmt.Errorf("unknown app %q: apps must be a subset of sonarr/radarr/prowlarr", a)
}
if !seen[a] {
seen[a] = true
out = append(out, a)
}
}
sort.Strings(out)
return out, nil
}
func pathRole(b *arrstackBackend) *framework.Path {
return &framework.Path{
Pattern: "roles/" + framework.GenericNameRegex("name"),
DisplayAttrs: &framework.DisplayAttributes{
OperationPrefix: "arrstack",
OperationSuffix: "role",
},
Fields: map[string]*framework.FieldSchema{
"name": {
Type: framework.TypeLowerCaseString,
Description: "Name of the role.",
Required: true,
},
"apps": {
Type: framework.TypeCommaStringSlice,
Description: "Comma-separated subset of sonarr/radarr/prowlarr a minted token may reach.",
Required: true,
},
"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 constrain minted arrproxy tokens.",
HelpDescription: "Roles define the allowed apps and TTLs applied to machine tokens issued from creds/<name>.",
}
}
func pathRolesList(b *arrstackBackend) *framework.Path {
return &framework.Path{
Pattern: "roles/?$",
DisplayAttrs: &framework.DisplayAttributes{
OperationPrefix: "arrstack",
OperationSuffix: "roles",
},
Operations: map[logical.Operation]framework.OperationHandler{
logical.ListOperation: &framework.PathOperation{
Callback: b.pathRolesList,
},
},
HelpSynopsis: "List the configured roles.",
HelpDescription: "List the roles configured on this arrstack backend.",
}
}
func (b *arrstackBackend) 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 *arrstackBackend) 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 *arrstackBackend) 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: role.toResponseData()}, nil
}
func (b *arrstackBackend) pathRoleWrite(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
name := data.Get("name").(string)
if name == "" {
return logical.ErrorResponse("role name is required"), nil
}
role, err := b.getRole(ctx, req.Storage, name)
if err != nil {
return nil, err
}
if role == nil {
role = &arrstackRole{}
}
if v, ok := data.GetOk("apps"); ok {
apps, err := validateApps(v.([]string))
if err != nil {
return logical.ErrorResponse(err.Error()), nil
}
role.Apps = apps
}
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 len(role.Apps) == 0 {
return logical.ErrorResponse("apps must be a non-empty subset of sonarr/radarr/prowlarr"), nil
}
if role.MaxTTL != 0 && role.TTL > role.MaxTTL {
return logical.ErrorResponse("ttl must not be greater than max_ttl"), nil
}
if err := setRole(ctx, req.Storage, name, role); err != nil {
return nil, err
}
return nil, nil
}
func (b *arrstackBackend) pathRoleDelete(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
if err := req.Storage.Delete(ctx, roleStoragePrefix+data.Get("name").(string)); err != nil {
return nil, fmt.Errorf("error deleting arrstack role: %w", err)
}
return nil, nil
}
func (b *arrstackBackend) getRole(ctx context.Context, s logical.Storage, name string) (*arrstackRole, error) {
if name == "" {
return nil, fmt.Errorf("missing role name")
}
entry, err := s.Get(ctx, roleStoragePrefix+name)
if err != nil {
return nil, err
}
if entry == nil {
return nil, nil
}
role := &arrstackRole{}
if err := entry.DecodeJSON(role); err != nil {
return nil, err
}
return role, nil
}
func setRole(ctx context.Context, s logical.Storage, name string, role *arrstackRole) error {
entry, err := logical.StorageEntryJSON(roleStoragePrefix+name, role)
if err != nil {
return err
}
if entry == nil {
return fmt.Errorf("failed to create storage entry for role %q", name)
}
return s.Put(ctx, entry)
}
+183
View File
@@ -0,0 +1,183 @@
package arrstack
import (
"context"
"testing"
"github.com/hashicorp/vault/sdk/logical"
)
func TestRole_WriteReadListDelete(t *testing.T) {
b, s := getTestBackend(t)
ctx := context.Background()
resp, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.CreateOperation,
Path: "roles/media",
Storage: s,
Data: map[string]interface{}{
"apps": "sonarr,radarr",
"ttl": "1h",
"max_ttl": "24h",
},
})
if err != nil || (resp != nil && resp.IsError()) {
t.Fatalf("write role: err=%v resp=%v", err, resp)
}
resp, err = b.HandleRequest(ctx, &logical.Request{
Operation: logical.ReadOperation,
Path: "roles/media",
Storage: s,
})
if err != nil || resp == nil {
t.Fatalf("read role: err=%v resp=%v", err, resp)
}
apps := resp.Data["apps"].([]string)
// Stored de-duplicated and sorted.
if len(apps) != 2 || apps[0] != "radarr" || apps[1] != "sonarr" {
t.Fatalf("unexpected apps: %v", apps)
}
if resp.Data["ttl"].(int64) != 3600 {
t.Fatalf("unexpected ttl: %v", resp.Data["ttl"])
}
if resp.Data["max_ttl"].(int64) != 86400 {
t.Fatalf("unexpected max_ttl: %v", resp.Data["max_ttl"])
}
resp, err = b.HandleRequest(ctx, &logical.Request{
Operation: logical.ListOperation,
Path: "roles/",
Storage: s,
})
if err != nil {
t.Fatalf("list roles: %v", err)
}
keys := resp.Data["keys"].([]string)
if len(keys) != 1 || keys[0] != "media" {
t.Fatalf("unexpected role list: %v", keys)
}
if _, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.DeleteOperation,
Path: "roles/media",
Storage: s,
}); err != nil {
t.Fatalf("delete role: %v", err)
}
role, _ := b.getRole(ctx, s, "media")
if role != nil {
t.Fatal("expected role to be gone after delete")
}
}
func TestRole_AllThreeApps(t *testing.T) {
b, s := getTestBackend(t)
ctx := context.Background()
resp, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.CreateOperation,
Path: "roles/all",
Storage: s,
Data: map[string]interface{}{"apps": "sonarr,radarr,prowlarr"},
})
if err != nil || (resp != nil && resp.IsError()) {
t.Fatalf("write role: err=%v resp=%v", err, resp)
}
role, _ := b.getRole(ctx, s, "all")
if len(role.Apps) != 3 {
t.Fatalf("expected 3 apps, got %v", role.Apps)
}
}
func TestRole_UnknownAppRejected(t *testing.T) {
b, s := getTestBackend(t)
ctx := context.Background()
resp, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.CreateOperation,
Path: "roles/bad",
Storage: s,
Data: map[string]interface{}{"apps": "sonarr,lidarr"},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp == nil || !resp.IsError() {
t.Fatal("expected an error for an app outside sonarr/radarr/prowlarr")
}
}
func TestRole_EmptyAppsRejected(t *testing.T) {
b, s := getTestBackend(t)
ctx := context.Background()
resp, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.CreateOperation,
Path: "roles/empty",
Storage: s,
Data: map[string]interface{}{"ttl": "1h"},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp == nil || !resp.IsError() {
t.Fatal("expected an error when apps is empty")
}
}
func TestRole_TTLGreaterThanMaxTTLRejected(t *testing.T) {
b, s := getTestBackend(t)
ctx := context.Background()
resp, err := b.HandleRequest(ctx, &logical.Request{
Operation: logical.CreateOperation,
Path: "roles/bad",
Storage: s,
Data: map[string]interface{}{
"apps": "sonarr",
"ttl": "48h",
"max_ttl": "1h",
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp == nil || !resp.IsError() {
t.Fatal("expected error when ttl > max_ttl")
}
}
func TestValidateApps(t *testing.T) {
cases := []struct {
name string
in []string
want []string
wantErr bool
}{
{"single", []string{"sonarr"}, []string{"sonarr"}, false},
{"dedup and sort", []string{"radarr", "sonarr", "radarr"}, []string{"radarr", "sonarr"}, false},
{"all three", []string{"prowlarr", "sonarr", "radarr"}, []string{"prowlarr", "radarr", "sonarr"}, false},
{"empty", nil, nil, true},
{"unknown", []string{"sonarr", "lidarr"}, nil, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := validateApps(tc.in)
if (err != nil) != tc.wantErr {
t.Fatalf("validateApps err=%v wantErr=%v", err, tc.wantErr)
}
if tc.wantErr {
return
}
if len(got) != len(tc.want) {
t.Fatalf("got %v want %v", got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Fatalf("got %v want %v", got, tc.want)
}
}
})
}
}
+54
View File
@@ -0,0 +1,54 @@
#!/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). Both wrap the same binary.
# 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}" # strip a leading v
BINARY="vault-plugin-secrets-arrstack"
DIST="dist"
if [ ! -f "${DIST}/${BINARY}" ]; then
echo "ERROR: ${DIST}/${BINARY} not found; run 'make build' first" >&2
exit 1
fi
# The plugin is registered in Vault/OpenBao by the sha256 of the binary, and
# Puppet pins that same digest. Print it so the release log records it.
SHA256="$(sha256sum "${DIST}/${BINARY}" | cut -d' ' -f1)"
echo "PLUGIN_SHA256 ${BINARY} ${VERSION} ${SHA256}"
# Fields shared across every flavour.
export PACKAGE_VERSION="${VERSION}"
export PACKAGE_RELEASE="1"
export PACKAGE_ARCH="amd64"
export PACKAGE_PLATFORM="linux"
export PACKAGE_DESCRIPTION="Vault/OpenBao dynamic secrets engine for arrproxy machine tokens"
export PACKAGE_MAINTAINER="Ben Vincent <ben@unkin.net>"
export PACKAGE_HOMEPAGE="https://git.unkin.net/unkin/vault-plugin-secrets-arrstack"
export PACKAGE_LICENSE="MIT"
# build_flavor <package-name> <plugin-dir>
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-arrstack" "/opt/vault-plugins"
build_flavor "openbao-plugin-secrets-arrstack" "/opt/openbao-plugins"
echo "Built:"
ls -1 "${DIST}"/*.rpm
+107
View File
@@ -0,0 +1,107 @@
package arrstack
import (
"context"
"errors"
"fmt"
"time"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
)
// arrstackTokenType is the identifier for the dynamic secret produced by this
// backend.
const arrstackTokenType = "arrstack_token"
func (b *arrstackBackend) arrstackToken() *framework.Secret {
return &framework.Secret{
Type: arrstackTokenType,
Fields: map[string]*framework.FieldSchema{
"token": {
Type: framework.TypeString,
Description: "The arrproxy machine token.",
},
"id": {
Type: framework.TypeString,
Description: "The arrproxy token id (used for revocation).",
},
},
Revoke: b.tokenRevoke,
Renew: b.tokenRenew,
}
}
// tokenRevoke disables the token in arrproxy when the lease is revoked.
func (b *arrstackBackend) tokenRevoke(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) {
rawID, ok := req.Secret.InternalData["id"]
if !ok {
return nil, errors.New("secret is missing internal id data")
}
id, ok := rawID.(string)
if !ok {
return nil, errors.New("secret internal id data is not a string")
}
client, err := b.getClient(ctx, req.Storage)
if err != nil {
return nil, err
}
if err := client.RevokeToken(ctx, id); err != nil {
return nil, fmt.Errorf("revoking arrproxy token: %w", err)
}
return nil, nil
}
// tokenRenew extends the lease honoring the role's max_ttl. The arrproxy token
// has a fixed expiry set at mint time, so a renewal can never push the lease
// past that expiry: the new TTL is capped at the remaining time to expiry, and
// an already-expired token is not extended.
func (b *arrstackBackend) tokenRenew(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) {
resp := &logical.Response{Secret: req.Secret}
rawRole, ok := req.Secret.InternalData["role"]
if !ok {
return nil, errors.New("secret is missing internal role data")
}
roleName, ok := rawRole.(string)
if !ok {
return nil, errors.New("secret internal role data is not a string")
}
role, err := b.getRole(ctx, req.Storage, roleName)
if err != nil {
return nil, err
}
if role == nil {
return nil, fmt.Errorf("role %q no longer exists; cannot renew", roleName)
}
remaining := time.Duration(-1)
if rawExp, ok := req.Secret.InternalData["expires_at"].(string); ok {
if expiresAt, err := time.Parse(time.RFC3339, rawExp); err == nil {
remaining = time.Until(expiresAt)
}
}
// The token has already reached its fixed expiry: do not extend.
if remaining <= 0 {
resp.Secret.TTL = 0
return resp, nil
}
ttl := role.TTL
if ttl <= 0 {
ttl = req.Secret.TTL
}
if ttl <= 0 || ttl > remaining {
ttl = remaining
}
resp.Secret.TTL = ttl
if role.MaxTTL > 0 {
resp.Secret.MaxTTL = role.MaxTTL
}
return resp, nil
}