Scaffold vault-plugin-secrets-arrstack engine
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful

Mint dynamic arrproxy machine tokens via arrproxy's bearer-gated admin API
so Terraform-driven *arr onboarding can issue and revoke per-role tokens
non-interactively.

- Add backend, config, roles, creds paths and the arrstack_token secret
- Call POST/DELETE /api/admin/tokens with a vault:arrstack:<role> subject
- Enforce apps as a non-empty subset of sonarr/radarr/prowlarr
- Cap lease renewal at the arrproxy token's fixed expiry
- Add table-driven unit tests against a fake arrproxy admin server
- Add Makefile, nfpm packaging, and pre-commit/build/test/release pipelines
This commit is contained in:
2026-08-18 21:52:07 +10:00
parent 1287e1b27e
commit 3418cfd8f6
25 changed files with 2657 additions and 1 deletions
+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.
`