From 20613afb2621e7b9a4392babc02b5e0e8137ada4 Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Mon, 27 Jul 2026 00:54:59 +1000 Subject: [PATCH] Initial vault-plugin-secrets-gitea engine Add a Vault/OpenBao secrets engine that mints ephemeral, scoped Gitea access tokens on demand. The engine holds a single seeded Gitea site-admin Basic-Auth credential and, per role, mints a fresh per-user token via the admin API, bound to a Vault lease and deleted from Gitea on revocation. Gitea requires Basic Auth for token management (token auth is rejected), and reqSelfOrAdmin lets a site admin manage any user's tokens, which is the mechanism this relies on. Gitea tokens never expire server-side, so the Vault lease is the sole expiry mechanism. - add backend wiring, config (+ rotate-root), roles, creds paths - add the gitea client (Basic Auth create/delete token, admin password change) - add scope validation against Gitea's access-token scope set - add unit tests (fake Gitea API) and a Vault+OpenBao e2e harness - add Makefile, nfpm RPM packaging, and Woodpecker build/test/release pipelines Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv --- .gitignore | 6 + .pre-commit-config.yaml | 15 + .woodpecker/build.yml | 18 + .woodpecker/pre-commit.yaml | 18 + .woodpecker/release.yml | 47 +++ .woodpecker/test.yml | 33 ++ Makefile | 77 +++++ README.md | 134 ++++++++ backend.go | 106 ++++++ backend_test.go | 449 +++++++++++++++++++++++++ client.go | 195 +++++++++++ client_test.go | 101 ++++++ cmd/vault-plugin-secrets-gitea/main.go | 34 ++ go.mod | 90 +++++ go.sum | 340 +++++++++++++++++++ packaging/nfpm.yaml | 35 ++ packaging/scripts/preinstall.sh.tmpl | 4 + path_config.go | 220 ++++++++++++ path_config_rotate.go | 101 ++++++ path_creds.go | 114 +++++++ path_roles.go | 201 +++++++++++ scopes.go | 73 ++++ scopes_test.go | 53 +++ scripts/build-rpm.sh | 44 +++ scripts/e2e.sh | 102 ++++++ secret_token.go | 83 +++++ test/docker-compose.yml | 64 ++++ test/mockgitea/main.go | 168 +++++++++ test/openbao/bao.hcl | 4 + test/vault/vault.hcl | 4 + 30 files changed, 2933 insertions(+) create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 .woodpecker/build.yml create mode 100644 .woodpecker/pre-commit.yaml create mode 100644 .woodpecker/release.yml create mode 100644 .woodpecker/test.yml create mode 100644 Makefile create mode 100644 README.md create mode 100644 backend.go create mode 100644 backend_test.go create mode 100644 client.go create mode 100644 client_test.go create mode 100644 cmd/vault-plugin-secrets-gitea/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 packaging/nfpm.yaml create mode 100755 packaging/scripts/preinstall.sh.tmpl create mode 100644 path_config.go create mode 100644 path_config_rotate.go create mode 100644 path_creds.go create mode 100644 path_roles.go create mode 100644 scopes.go create mode 100644 scopes_test.go create mode 100755 scripts/build-rpm.sh create mode 100755 scripts/e2e.sh create mode 100644 secret_token.go create mode 100644 test/docker-compose.yml create mode 100644 test/mockgitea/main.go create mode 100644 test/openbao/bao.hcl create mode 100644 test/vault/vault.hcl diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1ca16df --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/dist/ +/vault-plugin-secrets-gitea +*.out +*.test +.env +test/plugins/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..5b65ffe --- /dev/null +++ b/.pre-commit-config.yaml @@ -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 diff --git a/.woodpecker/build.yml b/.woodpecker/build.yml new file mode 100644 index 0000000..74123b5 --- /dev/null +++ b/.woodpecker/build.yml @@ -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 diff --git a/.woodpecker/pre-commit.yaml b/.woodpecker/pre-commit.yaml new file mode 100644 index 0000000..d57b508 --- /dev/null +++ b/.woodpecker/pre-commit.yaml @@ -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 diff --git a/.woodpecker/release.yml b/.woodpecker/release.yml new file mode 100644 index 0000000..f1a4b5c --- /dev/null +++ b/.woodpecker/release.yml @@ -0,0 +1,47 @@ +when: + - event: tag + +steps: + - name: build + image: git.unkin.net/unkin/almalinux9-gobuilder:20260606 + commands: + - make build VERSION=${CI_COMMIT_TAG} + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: {memory: 512Mi, cpu: 1} + limits: {memory: 2Gi, cpu: 2} + + - name: package + image: git.unkin.net/unkin/almalinux9-rpmbuilder:latest + commands: + - ./scripts/build-rpm.sh ${CI_COMMIT_TAG} + depends_on: [build] + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: {memory: 512Mi, cpu: 1} + limits: {memory: 2Gi, cpu: 2} + + - name: upload + image: git.unkin.net/unkin/almalinux9-base:20260606 + commands: + - | + HOST="https://artifactapi.k8s.syd1.au.unkin.net" + REPO="rpm-internal" + for rpm in dist/*.rpm; do + FILE=$$(basename "$$rpm") + code=$$(curl -s -o /dev/null -w '%{http_code}' "$$HOST/api/v2/remotes/$$REPO/files/Packages/$$FILE" || true) + if [ "$$code" = "200" ]; then echo "$$FILE exists; skipping"; continue; fi + echo "Uploading $$FILE (probe $$code)" + curl -f -X PUT "$$HOST/api/v2/remotes/$$REPO/files/$$FILE" -H "Content-Type: application/x-rpm" --data-binary @"$$rpm" + done + depends_on: [package] + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: {memory: 128Mi, cpu: 100m} + limits: {memory: 512Mi, cpu: 500m} diff --git a/.woodpecker/test.yml b/.woodpecker/test.yml new file mode 100644 index 0000000..8e81e45 --- /dev/null +++ b/.woodpecker/test.yml @@ -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 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..98d4930 --- /dev/null +++ b/Makefile @@ -0,0 +1,77 @@ +.PHONY: build install test lint fmt clean tidy rpm rpm-package patch minor major check-go e2e e2e-vault e2e-openbao e2e-up e2e-down + +BINARY := vault-plugin-secrets-gitea +PKG := ./cmd/vault-plugin-secrets-gitea +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= secret $(BINARY))" + +test: check-go + go test -race -count=1 ./... + +lint: check-go + go vet ./... + +fmt: check-go + gofmt -w . + +tidy: + go mod tidy + +clean: + rm -rf $(PLUGIN_DIR) + +rpm: build rpm-package + +rpm-package: + ./scripts/build-rpm.sh $(VERSION) + +# End-to-end tests bring up a mock Gitea API plus Vault and OpenBao in Docker and +# drive the full lifecycle against each with the same plugin binary. +e2e: + ./scripts/e2e.sh + +e2e-vault: + ENGINES=vault ./scripts/e2e.sh + +e2e-openbao: + ENGINES=openbao ./scripts/e2e.sh + +e2e-up: + docker compose -f test/docker-compose.yml up -d --build + +e2e-down: + docker compose -f test/docker-compose.yml down -v + +_LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1) +_BASE := $(if $(_LATEST),$(_LATEST),v0.0.0) +_MAJ := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f1) +_MIN := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f2) +_PAT := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f3) + +patch: + @NEW=v$(_MAJ).$(_MIN).$(shell expr $(_PAT) + 1); \ + git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW + +minor: + @NEW=v$(_MAJ).$(shell expr $(_MIN) + 1).0; \ + git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW + +major: + @NEW=v$(shell expr $(_MAJ) + 1).0.0; \ + git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW diff --git a/README.md b/README.md new file mode 100644 index 0000000..516ae33 --- /dev/null +++ b/README.md @@ -0,0 +1,134 @@ +# vault-plugin-secrets-gitea + +A Vault / OpenBao secrets engine that mints **ephemeral, scoped Gitea access +tokens** on demand. + +## Why + +Static Gitea bot users (teabot personalities, CI identities, ...) should never +hold long-lived personal access tokens: a leaked token is valid until someone +notices and deletes it, and Gitea tokens **never expire on their own**. This +engine removes standing tokens entirely: + +- You seed it once with a single Gitea **site-admin** credential. +- A **role** binds a target Gitea username to a set of token scopes and TTLs. +- Each read of `creds/` mints a fresh token for that user, bound to a + Vault lease, and **deletes it from Gitea when the lease is revoked or expires**. + +### Why an admin username + password, not an admin token + +Gitea's token-management endpoints (`POST/DELETE /api/v1/users/{username}/tokens`) +are guarded by `reqBasicOrRevProxyAuth()` — they **reject token/bearer auth** and +accept only HTTP Basic Auth or reverse-proxy auth +([go-gitea/gitea#21186](https://github.com/go-gitea/gitea/issues/21186)). The +same routes are also guarded by `reqSelfOrAdmin()`, so a **site admin** using +Basic Auth may mint and delete tokens for *any* user by naming them in the path. +That is the mechanism this engine relies on, which is why the seeded credential +is an admin **username + password**, not a token. + +### The Vault lease is the only expiry + +Gitea access tokens have no server-side TTL. The Vault lease is therefore the +sole expiry mechanism: when the lease is revoked or reaches `max_ttl`, the engine +issues `DELETE /api/v1/users/{username}/tokens/{id}` to remove the token. A +delete that returns 404 (token already gone) is treated as success, so revocation +is idempotent and Vault's retries converge. + +## Paths + +| Path | Description | +|------|-------------| +| `config` | Gitea URL + TLS settings + seeded admin `admin_username`/`admin_password`. Verifies the credentials are a site admin on write. | +| `config/rotate-root` | Rotate the seeded admin password in place (generates a new random password, changes it via the admin API, stores it). | +| `roles/` | Mint policy: `username`, `scopes`, `ttl`, `max_ttl`, `token_name_prefix`. | +| `roles` | List roles. | +| `creds/` | Read to mint a short-lived, lease-bound token for the role's user. | + +## Scopes + +`scopes` is validated against Gitea's scope set (see +`models/auth/access_token_scope.go`): `all`, `public-only`, and the `read:` / +`write:` forms of `activitypub`, `admin`, `misc`, `notification`, `organization`, +`package`, `issue`, `repository`, `user`. `write:` implies `read:`. + +## Usage + +```sh +vault secrets enable -path=gitea vault-plugin-secrets-gitea + +# Seed with a Gitea site-admin username + password (Basic Auth). +vault write gitea/config \ + gitea_url=https://git.example.com \ + admin_username=bot-admin \ + admin_password='...' \ + ca_cert=@gitea-ca.pem + +# Immediately rotate the seeded password so only Vault knows it. +vault write -f gitea/config/rotate-root + +# A role that mints 1h tokens for the "teabot" user, scoped to repo + issues. +vault write gitea/roles/teabot username=teabot \ + scopes=read:repository,write:issue ttl=1h max_ttl=24h + +# Mint one. The token is deleted from Gitea when the lease is revoked. +vault read gitea/creds/teabot +``` + +### Root rotation requirements & limits + +`config/rotate-root` changes the seeded admin's password via +`PATCH /api/v1/admin/users/{admin_username}`. Honestly documented constraints: + +- The admin must be a **local** Gitea user (external-auth users can't have their + password changed this way). Gitea requires `login_name` on the edit call; the + engine sends `admin_login_name` (default: `admin_username`) and `admin_source_id` + (default `0`, i.e. local). +- The admin account must **not** have TOTP/2FA enabled — Basic Auth with 2FA + requires an OTP header the engine cannot supply. +- Rotation changes Gitea first, then persists to Vault. If the persist fails the + engine rolls the password back. If *both* the persist and the rollback fail + (extremely unlikely), the response says so — reset the admin password manually + and re-seed `config`. + +## Development + +```sh +make build # build the plugin binary into ./dist +make test # unit tests (race) +make e2e # full lifecycle vs mock Gitea on Vault + OpenBao (Docker) +make rpm # build Vault + OpenBao RPMs via nfpm +``` + +Releases are tag-driven (`make patch|minor|major`): a Woodpecker pipeline builds +the Vault and OpenBao RPMs and uploads them to the internal artifactapi yum repo. + +## Deployment (out of scope for this repo; documented for the operator) + +Live registration in the real cluster is done exactly like the sibling +`vault-plugin-secrets-rancher` engine. The steps, in order: + +1. **Repos**: the Gitea repo is provisioned via `terraform-git` + (`config/git.unkin.net/unkin/repository/vault-plugin-secrets-gitea.yaml`) → + PR → plan/apply. After the repo auto-inits, enable its Woodpecker webhook + manually in the Woodpecker UI (new repos get no webhook automatically) so the + required `pre-commit`/`build`/`test` checks run. +2. **Release**: tag `v0.1.0` (`make minor` from `v0.0.0`). CI publishes + `rpm-internal/files/Packages/{vault,openbao}-plugin-secrets-gitea--1.x86_64.rpm`. +3. **terraform-vault** (mirrors the rancher wiring — merge in this order): + - `policies/gitea/admin.yaml` — deployer catalog + engine grant. + - `config/plugins/vault-plugin-secrets-gitea.yaml` — plugin catalog entry + with the release `sha256`. + - `puppet-prod`: add `openbao-plugin-secrets-gitea` (pinned to the exact + version) to `profiles::packages::include` on the vault storage role. + - `gitea_secret_backend` + `gitea_secret_backend_role` module instances and + their config yamls (via the companion `terraform-provider-giteavaultsecret`). +4. **Seed the admin credential in KV** before the backend module applies, e.g. + `kv/service/vault/au/syd1/secret_backend/gitea/config` with + `admin_username` + `admin_password` for a purpose-built Gitea site-admin bot + account (2FA disabled). Then run `vault write -f gitea/config/rotate-root` so + the standing seed password is replaced by one only Vault holds. +5. **Reload after a binary upgrade**: + `vault write sys/plugins/reload/backend plugin=vault-plugin-secrets-gitea` + (the `vault plugin reload -plugin=…` CLI form is broken on this OpenBao). + Bump the RPM version in puppet-prod and the catalog `sha256` in terraform-vault + *together* so the on-disk binary stays in lockstep with the catalog. diff --git a/backend.go b/backend.go new file mode 100644 index 0000000..af8ebbb --- /dev/null +++ b/backend.go @@ -0,0 +1,106 @@ +// Package gitea implements a Vault / OpenBao secrets engine that mints +// ephemeral, scoped Gitea personal access tokens on demand. +// +// Static Gitea bot users (teabot personalities, CI identities, ...) should never +// hold long-lived tokens. The engine is seeded with a single Gitea *site admin* +// credential (username + password, HTTP Basic Auth) and, on each read of +// creds/, mints a fresh access token for the role's target user via the +// admin API (POST /api/v1/users/{username}/tokens). Each minted token is bound +// to a Vault lease and deleted from Gitea (DELETE .../tokens/{id}) when the +// lease expires or is revoked. +// +// Why Basic Auth and not an admin token: Gitea's token-management endpoints are +// guarded by reqBasicOrRevProxyAuth() — you cannot create or delete a token +// using token/bearer auth, only Basic Auth or reverse-proxy auth (see +// go-gitea/gitea#21186). The same routes are guarded by reqSelfOrAdmin(), so a +// site admin authenticating with Basic Auth may mint and delete tokens for *any* +// user by naming them in the path. That is exactly the mechanism this engine +// relies on, which is why the seeded credential is an admin username+password. +// +// Gitea access tokens have no server-side expiry: once created a token lives +// until it is deleted. The Vault lease is therefore the *only* expiry mechanism +// — lease revocation deletes the token, and that is what bounds its lifetime. +package gitea + +import ( + "context" + "errors" + "strings" + "sync" + + "github.com/hashicorp/vault/sdk/framework" + "github.com/hashicorp/vault/sdk/logical" +) + +// errBackendNotConfigured is returned when a credential is requested before the +// Gitea connection has been configured. +var errBackendNotConfigured = errors.New("gitea backend not configured; write config first") + +type giteaBackend struct { + *framework.Backend + + // lock serialises root-credential rotation against credential issuance so a + // mint never races a password change out from under it. + lock sync.RWMutex +} + +// Factory returns a configured Gitea 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() *giteaBackend { + b := &giteaBackend{} + + b.Backend = &framework.Backend{ + Help: strings.TrimSpace(backendHelp), + BackendType: logical.TypeLogical, + PathsSpecial: &logical.Paths{ + SealWrapStorage: []string{configStoragePath}, + }, + Paths: framework.PathAppend( + []*framework.Path{ + pathConfig(b), + pathConfigRotateRoot(b), + pathRole(b), + pathRolesList(b), + pathCredentials(b), + }, + ), + Secrets: []*framework.Secret{ + b.giteaTokenSecret(), + }, + } + + return b +} + +// clientFor builds a Gitea client from the stored config, authenticated with the +// seeded admin Basic-Auth credentials. +func (b *giteaBackend) clientFor(ctx context.Context, s logical.Storage) (*giteaClient, error) { + config, err := getConfig(ctx, s) + if err != nil { + return nil, err + } + if config == nil { + return nil, errBackendNotConfigured + } + return newClient(config) +} + +const backendHelp = ` +The gitea secrets engine mints ephemeral, scoped Gitea access tokens. + +Seed the engine with a Gitea site-admin username and password (Basic Auth); +Gitea only permits token management via Basic Auth, and a site admin may manage +tokens for any user. Roles bind a target Gitea username to a set of token scopes +and TTLs; each read of creds/ mints a fresh token for that user, bound to +a Vault lease and deleted from Gitea on revocation. Gitea tokens never expire +server-side, so the Vault lease is the only thing that bounds their lifetime. + +Use config/rotate-root to rotate the seeded admin password in place. +` diff --git a/backend_test.go b/backend_test.go new file mode 100644 index 0000000..f8c2b88 --- /dev/null +++ b/backend_test.go @@ -0,0 +1,449 @@ +package gitea + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync" + "testing" + + "github.com/hashicorp/vault/sdk/logical" +) + +// fakeGitea is an in-memory stand-in for the Gitea REST API covering just the +// endpoints the plugin uses: token create/delete, admin password change, and +// whoami. Basic Auth is validated against the *current* admin credentials, so +// tests exercise password rotation exactly as production does. +type fakeGitea struct { + mu sync.Mutex + adminU string + adminP string + nextID int64 + tokens map[string]storedToken // key: username/id + minted int + adminHit int +} + +type storedToken struct { + id int64 + name string + sha1 string + scopes []string + username string +} + +func newFakeGitea(adminUser, adminPass string) *fakeGitea { + return &fakeGitea{ + adminU: adminUser, + adminP: adminPass, + tokens: map[string]storedToken{}, + } +} + +func key(username, id string) string { return username + "/" + id } + +func (f *fakeGitea) server(t *testing.T) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + u, p, ok := r.BasicAuth() + f.mu.Lock() + validAdmin := ok && u == f.adminU && p == f.adminP + f.mu.Unlock() + if !validAdmin { + w.WriteHeader(http.StatusUnauthorized) + return + } + + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/user": + writeJSON(w, http.StatusOK, map[string]interface{}{"login": u, "is_admin": true}) + + case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/api/v1/users/") && strings.HasSuffix(r.URL.Path, "/tokens"): + username := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/api/v1/users/"), "/tokens") + var in createTokenOption + _ = json.NewDecoder(r.Body).Decode(&in) + f.mu.Lock() + f.nextID++ + id := f.nextID + f.minted++ + sha := "sha1-" + strconv.FormatInt(id, 10) + f.tokens[key(username, strconv.FormatInt(id, 10))] = storedToken{ + id: id, name: in.Name, sha1: sha, scopes: in.Scopes, username: username, + } + f.mu.Unlock() + writeJSON(w, http.StatusCreated, map[string]interface{}{ + "id": id, "name": in.Name, "sha1": sha, "token_last_eight": "lasteig8", "scopes": in.Scopes, + }) + + case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/tokens/"): + // /api/v1/users/{username}/tokens/{id} + rest := strings.TrimPrefix(r.URL.Path, "/api/v1/users/") + parts := strings.SplitN(rest, "/tokens/", 2) + if len(parts) != 2 { + w.WriteHeader(http.StatusNotFound) + return + } + f.mu.Lock() + k := key(parts[0], parts[1]) + _, exists := f.tokens[k] + if exists { + delete(f.tokens, k) + } + f.mu.Unlock() + if !exists { + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusNoContent) + + case r.Method == http.MethodPatch && strings.HasPrefix(r.URL.Path, "/api/v1/admin/users/"): + var in editUserOption + _ = json.NewDecoder(r.Body).Decode(&in) + f.mu.Lock() + f.adminHit++ + if in.Password != "" { + f.adminP = in.Password + } + f.mu.Unlock() + w.WriteHeader(http.StatusOK) + + default: + w.WriteHeader(http.StatusNotFound) + } + })) +} + +func (f *fakeGitea) has(username, id string) bool { + f.mu.Lock() + defer f.mu.Unlock() + _, ok := f.tokens[key(username, id)] + return ok +} + +func writeJSON(w http.ResponseWriter, code int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} + +func newTestBackend(t *testing.T) (*giteaBackend, logical.Storage) { + t.Helper() + config := logical.TestBackendConfig() + config.StorageView = &logical.InmemStorage{} + b, err := Factory(context.Background(), config) + if err != nil { + t.Fatalf("Factory: %v", err) + } + return b.(*giteaBackend), config.StorageView +} + +func req(t *testing.T, b *giteaBackend, s logical.Storage, op logical.Operation, path string, data map[string]interface{}) *logical.Response { + t.Helper() + resp, err := b.HandleRequest(context.Background(), &logical.Request{ + Operation: op, + Path: path, + Data: data, + Storage: s, + }) + if err != nil { + t.Fatalf("%s %s: %v", op, path, err) + } + if resp != nil && resp.IsError() { + t.Fatalf("%s %s: %v", op, path, resp.Error()) + } + return resp +} + +func configure(t *testing.T, b *giteaBackend, s logical.Storage, url string) { + t.Helper() + req(t, b, s, logical.CreateOperation, "config", map[string]interface{}{ + "gitea_url": url, + "admin_username": "bot-admin", + "admin_password": "seed-password", + }) +} + +func TestLifecycle(t *testing.T) { + fake := newFakeGitea("bot-admin", "seed-password") + srv := fake.server(t) + defer srv.Close() + + b, s := newTestBackend(t) + configure(t, b, s, srv.URL) + + req(t, b, s, logical.CreateOperation, "roles/teabot", map[string]interface{}{ + "username": "teabot", + "scopes": "read:repository,write:issue", + "ttl": "1h", + "max_ttl": "24h", + }) + + creds := req(t, b, s, logical.ReadOperation, "creds/teabot", nil) + if creds.Secret == nil { + t.Fatal("creds returned no secret") + } + tokenID, _ := creds.Data["token_id"].(string) + tokenVal, _ := creds.Data["token"].(string) + username, _ := creds.Data["username"].(string) + if tokenID == "" || tokenVal == "" { + t.Fatalf("creds missing token/token_id: %#v", creds.Data) + } + if username != "teabot" { + t.Errorf("username = %q, want teabot", username) + } + if creds.Data["gitea_url"].(string) != srv.URL { + t.Errorf("gitea_url = %q, want %q", creds.Data["gitea_url"], srv.URL) + } + if !fake.has("teabot", tokenID) { + t.Error("minted token not present in gitea") + } + + // Renew keeps the same secret. + if _, err := b.HandleRequest(context.Background(), &logical.Request{ + Operation: logical.RenewOperation, + Secret: creds.Secret, + Storage: s, + }); err != nil { + t.Fatalf("renew: %v", err) + } + + // Revoke deletes the token from gitea. + if _, err := b.HandleRequest(context.Background(), &logical.Request{ + Operation: logical.RevokeOperation, + Secret: creds.Secret, + Storage: s, + }); err != nil { + t.Fatalf("revoke: %v", err) + } + if fake.has("teabot", tokenID) { + t.Error("token still present after revoke") + } +} + +func TestRevokeIsIdempotent(t *testing.T) { + fake := newFakeGitea("bot-admin", "seed-password") + srv := fake.server(t) + defer srv.Close() + + b, s := newTestBackend(t) + configure(t, b, s, srv.URL) + req(t, b, s, logical.CreateOperation, "roles/teabot", map[string]interface{}{ + "username": "teabot", "scopes": "read:repository", + }) + creds := req(t, b, s, logical.ReadOperation, "creds/teabot", nil) + + revoke := func() error { + _, err := b.HandleRequest(context.Background(), &logical.Request{ + Operation: logical.RevokeOperation, + Secret: creds.Secret, + Storage: s, + }) + return err + } + if err := revoke(); err != nil { + t.Fatalf("first revoke: %v", err) + } + // A second revoke (token already gone → 404) must still succeed. + if err := revoke(); err != nil { + t.Fatalf("second revoke should be idempotent, got: %v", err) + } +} + +func TestScopeValidation(t *testing.T) { + fake := newFakeGitea("bot-admin", "seed-password") + srv := fake.server(t) + defer srv.Close() + + b, s := newTestBackend(t) + configure(t, b, s, srv.URL) + + resp, err := b.HandleRequest(context.Background(), &logical.Request{ + Operation: logical.CreateOperation, + Path: "roles/bad", + Data: map[string]interface{}{"username": "teabot", "scopes": "read:repository,bogus:scope"}, + Storage: s, + }) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if resp == nil || !resp.IsError() { + t.Fatal("expected error for invalid scope") + } + + // Duplicate + mixed-case scopes normalise down cleanly. + req(t, b, s, logical.CreateOperation, "roles/ok", map[string]interface{}{ + "username": "teabot", "scopes": "Read:Repository, read:repository ,write:issue", + }) + role := req(t, b, s, logical.ReadOperation, "roles/ok", nil) + got, _ := role.Data["scopes"].([]string) + if len(got) != 2 || got[0] != "read:repository" || got[1] != "write:issue" { + t.Errorf("normalised scopes = %v, want [read:repository write:issue]", got) + } +} + +func TestRoleRequiresUsernameAndScopes(t *testing.T) { + fake := newFakeGitea("bot-admin", "seed-password") + srv := fake.server(t) + defer srv.Close() + + b, s := newTestBackend(t) + configure(t, b, s, srv.URL) + + // Missing scopes. + resp, err := b.HandleRequest(context.Background(), &logical.Request{ + Operation: logical.CreateOperation, + Path: "roles/nos", + Data: map[string]interface{}{"username": "teabot"}, + Storage: s, + }) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if resp == nil || !resp.IsError() { + t.Fatal("expected error for role without scopes") + } +} + +func TestConfigRequiresAdminAndVerifies(t *testing.T) { + fake := newFakeGitea("bot-admin", "seed-password") + srv := fake.server(t) + defer srv.Close() + + b, s := newTestBackend(t) + + // Missing admin_password. + resp, err := b.HandleRequest(context.Background(), &logical.Request{ + Operation: logical.CreateOperation, + Path: "config", + Data: map[string]interface{}{"gitea_url": srv.URL, "admin_username": "bot-admin"}, + Storage: s, + }) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if resp == nil || !resp.IsError() { + t.Fatal("expected error when admin_password missing") + } + + // Wrong password fails verification against the fake (401). + resp, err = b.HandleRequest(context.Background(), &logical.Request{ + Operation: logical.CreateOperation, + Path: "config", + Data: map[string]interface{}{"gitea_url": srv.URL, "admin_username": "bot-admin", "admin_password": "wrong"}, + Storage: s, + }) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if resp == nil || !resp.IsError() { + t.Fatal("expected error when admin credentials fail verification") + } + + // config read must never leak the password. + configure(t, b, s, srv.URL) + read := req(t, b, s, logical.ReadOperation, "config", nil) + if _, leaked := read.Data["admin_password"]; leaked { + t.Fatal("config read leaked admin_password") + } +} + +func TestCredsBeforeConfig(t *testing.T) { + b, s := newTestBackend(t) + // Roles can be written without config, but minting from one before config is + // written must fail with the not-configured error. + req(t, b, s, logical.CreateOperation, "roles/x", map[string]interface{}{ + "username": "teabot", "scopes": "read:repository", + }) + _, err := b.HandleRequest(context.Background(), &logical.Request{ + Operation: logical.ReadOperation, + Path: "creds/x", + Storage: s, + }) + if err == nil { + t.Fatal("expected creds read to fail without config") + } +} + +func TestRotateRoot(t *testing.T) { + fake := newFakeGitea("bot-admin", "seed-password") + srv := fake.server(t) + defer srv.Close() + + b, s := newTestBackend(t) + configure(t, b, s, srv.URL) + + // Rotate the root password. + rot := req(t, b, s, logical.UpdateOperation, "config/rotate-root", nil) + if ok, _ := rot.Data["rotated"].(bool); !ok { + t.Fatal("rotate-root did not report success") + } + + // The fake now only accepts the new password; the engine's stored config must + // have been updated to match, so minting still works after rotation. + req(t, b, s, logical.CreateOperation, "roles/teabot", map[string]interface{}{ + "username": "teabot", "scopes": "read:repository", + }) + creds := req(t, b, s, logical.ReadOperation, "creds/teabot", nil) + if creds.Secret == nil { + t.Fatal("mint after rotate-root failed") + } + + // The stored password must no longer be the seed. + cfg, err := getConfig(context.Background(), s) + if err != nil { + t.Fatalf("getConfig: %v", err) + } + if cfg.AdminPassword == "seed-password" { + t.Error("admin_password was not changed by rotate-root") + } + if fake.adminHit == 0 { + t.Error("rotate-root did not call the gitea admin API") + } +} + +func TestRotateRootRollback(t *testing.T) { + fake := newFakeGitea("bot-admin", "seed-password") + srv := fake.server(t) + defer srv.Close() + + b, s := newTestBackend(t) + configure(t, b, s, srv.URL) + + // Force the storage write to fail so rotate-root must roll back. + failing := &failingStorage{Storage: s, failPut: true} + resp, err := b.HandleRequest(context.Background(), &logical.Request{ + Operation: logical.UpdateOperation, + Path: "config/rotate-root", + Storage: failing, + }) + if err == nil && (resp == nil || !resp.IsError()) { + t.Fatal("expected rotate-root to fail when storage write fails") + } + + // After rollback the seed password must work again: a normal config write + // (which verifies against gitea) should succeed with the original password. + configure(t, b, s, srv.URL) +} + +// failingStorage wraps a storage and can be told to fail Put, to exercise the +// rotate-root rollback path. +type failingStorage struct { + logical.Storage + failPut bool +} + +func (f *failingStorage) Put(ctx context.Context, entry *logical.StorageEntry) error { + if f.failPut && entry.Key == configStoragePath { + return errForcedPutFailure + } + return f.Storage.Put(ctx, entry) +} + +var errForcedPutFailure = &forcedError{"forced put failure"} + +type forcedError struct{ msg string } + +func (e *forcedError) Error() string { return e.msg } diff --git a/client.go b/client.go new file mode 100644 index 0000000..54ed40c --- /dev/null +++ b/client.go @@ -0,0 +1,195 @@ +package gitea + +import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" +) + +const defaultHTTPTimeout = 30 * time.Second + +// giteaClient talks to the Gitea REST API using the seeded site-admin +// credentials over HTTP Basic Auth. Basic Auth is mandatory: Gitea's +// token-management endpoints reject token/bearer auth (go-gitea/gitea#21186). +type giteaClient struct { + baseURL string + username string + password string + httpClient *http.Client +} + +// accessToken mirrors the subset of Gitea's AccessToken JSON we read. The token +// value (sha1) is only ever returned in the create response. +type accessToken struct { + ID int64 `json:"id"` + Name string `json:"name"` + SHA1 string `json:"sha1"` + TokenLastEight string `json:"token_last_eight"` + Scopes []string `json:"scopes"` +} + +// createTokenOption is Gitea's CreateAccessTokenOption request body. +type createTokenOption struct { + Name string `json:"name"` + Scopes []string `json:"scopes,omitempty"` +} + +// editUserOption is the subset of Gitea's EditUserOption used to rotate the +// admin password. Gitea binds login_name as Required, so it must be sent even +// for a password-only change. +type editUserOption struct { + LoginName string `json:"login_name"` + SourceID int64 `json:"source_id"` + Password string `json:"password"` +} + +func newClient(cfg *giteaConfig) (*giteaClient, error) { + if cfg == nil { + return nil, errors.New("gitea client configuration is nil") + } + if cfg.GiteaURL == "" { + return nil, errors.New("gitea_url is required") + } + if cfg.AdminUsername == "" || cfg.AdminPassword == "" { + return nil, errors.New("admin_username and admin_password are required") + } + + timeout := defaultHTTPTimeout + if cfg.RequestTimeoutSeconds > 0 { + timeout = time.Duration(cfg.RequestTimeoutSeconds) * time.Second + } + + tlsConfig := &tls.Config{InsecureSkipVerify: cfg.TLSSkipVerify} //nolint:gosec // opt-in via config + if cfg.CACert != "" { + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM([]byte(cfg.CACert)) { + return nil, errors.New("ca_cert is not a valid PEM certificate") + } + tlsConfig.RootCAs = pool + } + + return &giteaClient{ + baseURL: strings.TrimRight(cfg.GiteaURL, "/"), + username: cfg.AdminUsername, + password: cfg.AdminPassword, + httpClient: &http.Client{ + Timeout: timeout, + Transport: &http.Transport{TLSClientConfig: tlsConfig}, + }, + }, nil +} + +// withPassword returns a shallow copy of the client authenticating with a +// different password. Used to roll a rotation back to the previous password. +func (c *giteaClient) withPassword(password string) *giteaClient { + clone := *c + clone.password = password + return &clone +} + +// errNotFound flags a 404 so callers can treat absence as non-fatal. +var errNotFound = errors.New("not found") + +// CreateToken mints a new access token for username with the given scopes and +// returns its value (sha1) and numeric id (as a string). The admin's Basic Auth +// credentials authorise minting for another user (reqSelfOrAdmin). +func (c *giteaClient) CreateToken(ctx context.Context, username, name string, scopes []string) (value, id string, err error) { + body := createTokenOption{Name: name, Scopes: scopes} + var out accessToken + if err := c.do(ctx, http.MethodPost, "/api/v1/users/"+username+"/tokens", body, &out); err != nil { + return "", "", err + } + if out.SHA1 == "" { + return "", "", errors.New("gitea returned an empty token value") + } + return out.SHA1, strconv.FormatInt(out.ID, 10), nil +} + +// DeleteToken removes an access token from username by its id (Gitea also +// accepts the token name here). A missing token is treated as success. +func (c *giteaClient) DeleteToken(ctx context.Context, username, id string) error { + if id == "" { + return nil + } + err := c.do(ctx, http.MethodDelete, "/api/v1/users/"+username+"/tokens/"+id, nil, nil) + if errors.Is(err, errNotFound) { + return nil + } + return err +} + +// SetAdminPassword changes the seeded admin user's password via the admin API. +// login_name / source_id are echoed from config because Gitea requires them. +func (c *giteaClient) SetAdminPassword(ctx context.Context, username, loginName string, sourceID int64, newPassword string) error { + body := editUserOption{LoginName: loginName, SourceID: sourceID, Password: newPassword} + return c.do(ctx, http.MethodPatch, "/api/v1/admin/users/"+username, body, nil) +} + +// VerifyAdmin confirms the seeded credentials authenticate and belong to a site +// admin, returning a clear error otherwise. Used to fail config writes fast. +func (c *giteaClient) VerifyAdmin(ctx context.Context) error { + var out struct { + Login string `json:"login"` + IsAdmin bool `json:"is_admin"` + } + if err := c.do(ctx, http.MethodGet, "/api/v1/user", nil, &out); err != nil { + return err + } + if !out.IsAdmin { + return fmt.Errorf("user %q is not a Gitea site admin; the engine requires an admin to manage other users' tokens", out.Login) + } + return nil +} + +func (c *giteaClient) do(ctx context.Context, method, path string, payload, out interface{}) error { + var body io.Reader + if payload != nil { + raw, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("encoding request body: %w", err) + } + body = bytes.NewReader(raw) + } + + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body) + if err != nil { + return fmt.Errorf("building request: %w", err) + } + req.SetBasicAuth(c.username, c.password) + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("calling gitea %s %s: %w", method, path, err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + + if resp.StatusCode == http.StatusNotFound { + return errNotFound + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("gitea %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 gitea response: %w", err) + } + return nil +} diff --git a/client_test.go b/client_test.go new file mode 100644 index 0000000..2a42a93 --- /dev/null +++ b/client_test.go @@ -0,0 +1,101 @@ +package gitea + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func testClient(t *testing.T, url string) *giteaClient { + t.Helper() + c, err := newClient(&giteaConfig{ + GiteaURL: url, + AdminUsername: "bot-admin", + AdminPassword: "seed-password", + }) + if err != nil { + t.Fatalf("newClient: %v", err) + } + return c +} + +func TestClientCreateAndDeleteToken(t *testing.T) { + fake := newFakeGitea("bot-admin", "seed-password") + srv := fake.server(t) + defer srv.Close() + + c := testClient(t, srv.URL) + ctx := context.Background() + + value, id, err := c.CreateToken(ctx, "teabot", "vault-teabot-abcd1234", []string{"read:repository"}) + if err != nil { + t.Fatalf("CreateToken: %v", err) + } + if value == "" || id == "" { + t.Fatalf("CreateToken returned empty value/id: %q %q", value, id) + } + if !fake.has("teabot", id) { + t.Fatal("token not stored in fake") + } + + if err := c.DeleteToken(ctx, "teabot", id); err != nil { + t.Fatalf("DeleteToken: %v", err) + } + if fake.has("teabot", id) { + t.Fatal("token still present after delete") + } + + // Deleting a non-existent token (404) is treated as success. + if err := c.DeleteToken(ctx, "teabot", "999999"); err != nil { + t.Fatalf("DeleteToken of missing token should succeed, got: %v", err) + } +} + +func TestClientUnauthorized(t *testing.T) { + fake := newFakeGitea("bot-admin", "correct-password") + srv := fake.server(t) + defer srv.Close() + + c := testClient(t, srv.URL) // uses "seed-password", which is wrong here + _, _, err := c.CreateToken(context.Background(), "teabot", "x", []string{"read:repository"}) + if err == nil { + t.Fatal("expected unauthorized error with wrong password") + } + if !strings.Contains(err.Error(), "401") { + t.Errorf("expected 401 in error, got: %v", err) + } +} + +func TestClientVerifyAdminNonAdmin(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v1/user" { + writeJSON(w, http.StatusOK, map[string]interface{}{"login": "bot-admin", "is_admin": false}) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + c := testClient(t, srv.URL) + err := c.VerifyAdmin(context.Background()) + if err == nil { + t.Fatal("expected error for non-admin user") + } + if !strings.Contains(err.Error(), "not a Gitea site admin") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestClientRequiresCredentials(t *testing.T) { + if _, err := newClient(&giteaConfig{GiteaURL: "https://git.example.com", AdminUsername: "u"}); err == nil { + t.Fatal("expected error when admin_password missing") + } + if _, err := newClient(&giteaConfig{AdminUsername: "u", AdminPassword: "p"}); err == nil { + t.Fatal("expected error when gitea_url missing") + } + if _, err := newClient(&giteaConfig{GiteaURL: "x", AdminUsername: "u", AdminPassword: "p", CACert: "not-a-pem"}); err == nil { + t.Fatal("expected error for invalid ca_cert") + } +} diff --git a/cmd/vault-plugin-secrets-gitea/main.go b/cmd/vault-plugin-secrets-gitea/main.go new file mode 100644 index 0000000..4376bb0 --- /dev/null +++ b/cmd/vault-plugin-secrets-gitea/main.go @@ -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" + + gitea "git.unkin.net/unkin/vault-plugin-secrets-gitea" +) + +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: gitea.Factory, + TLSProviderFunc: tlsProviderFunc, + }) + if err != nil { + logger := hclog.New(&hclog.LoggerOptions{}) + logger.Error("plugin shutting down", "error", err) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7c76254 --- /dev/null +++ b/go.mod @@ -0,0 +1,90 @@ +module git.unkin.net/unkin/vault-plugin-secrets-gitea + +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.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/golang/snappy v0.0.4 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-immutable-radix v1.3.1 // indirect + github.com/hashicorp/go-kms-wrapping/entropy/v2 v2.0.0 // indirect + github.com/hashicorp/go-kms-wrapping/v2 v2.0.8 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-plugin v1.6.1 // indirect + github.com/hashicorp/go-retryablehttp v0.7.7 // indirect + github.com/hashicorp/go-rootcerts v1.0.2 // indirect + github.com/hashicorp/go-secure-stdlib/mlock v0.1.2 // indirect + github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8 // indirect + github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.0 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect + github.com/hashicorp/go-sockaddr v1.0.6 // indirect + github.com/hashicorp/go-version v1.6.0 // indirect + github.com/hashicorp/golang-lru v0.5.4 // indirect + github.com/hashicorp/hcl v1.0.1-vault-5 // indirect + github.com/hashicorp/yamux v0.1.1 // indirect + github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/go-testing-interface v1.14.1 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/oklog/run v1.1.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.0-rc2.0.20221005185240-3a7f492d3f1b // indirect + github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 // indirect + github.com/pierrec/lz4 v2.6.1+incompatible // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect + github.com/sasha-s/go-deadlock v0.2.0 // indirect + github.com/stretchr/testify v1.11.1 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.opentelemetry.io/proto/otlp v1.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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8ddefdb --- /dev/null +++ b/go.sum @@ -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.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg= +github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.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.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.28.0 h1:j9+03ymgYhPKmeXGk5Zu+cIZOlVzd9Zv7QIiyItjFBU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.28.0/go.mod h1:Y5+XiUG4Emn1hTfciPzGPJaSI+RpDts6BnCIir0SLqk= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/proto/otlp v1.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= diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml new file mode 100644 index 0000000..ec94a3b --- /dev/null +++ b/packaging/nfpm.yaml @@ -0,0 +1,35 @@ +--- +# nfpm config for the vault-plugin-secrets-gitea RPM. Rendered through envsubst +# (see scripts/build-rpm.sh) then fed to `nfpm pkg`. Built once per target server +# (Vault, OpenBao); PACKAGE_NAME and PACKAGE_PLUGIN_DIR vary. + +name: ${PACKAGE_NAME} +version: ${PACKAGE_VERSION} +release: ${PACKAGE_RELEASE} +arch: ${PACKAGE_ARCH} +platform: ${PACKAGE_PLATFORM} +section: default +priority: extra +description: "${PACKAGE_DESCRIPTION}" + +maintainer: ${PACKAGE_MAINTAINER} +homepage: ${PACKAGE_HOMEPAGE} +license: ${PACKAGE_LICENSE} + +disable_globbing: false + +replaces: + - ${PACKAGE_NAME} +provides: + - ${PACKAGE_NAME} + +contents: + - src: dist/vault-plugin-secrets-gitea + dst: ${PACKAGE_PLUGIN_DIR}/vault-plugin-secrets-gitea + file_info: + mode: 0755 + owner: root + group: root + +scripts: + preinstall: ${PACKAGE_PREINSTALL} diff --git a/packaging/scripts/preinstall.sh.tmpl b/packaging/scripts/preinstall.sh.tmpl new file mode 100755 index 0000000..e129bf4 --- /dev/null +++ b/packaging/scripts/preinstall.sh.tmpl @@ -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} diff --git a/path_config.go b/path_config.go new file mode 100644 index 0000000..5142dc3 --- /dev/null +++ b/path_config.go @@ -0,0 +1,220 @@ +package gitea + +import ( + "context" + "errors" + + "github.com/hashicorp/vault/sdk/framework" + "github.com/hashicorp/vault/sdk/logical" +) + +const configStoragePath = "config" + +// giteaConfig is the connection to Gitea plus the seeded admin Basic-Auth +// credentials the engine uses to manage other users' tokens. +type giteaConfig struct { + GiteaURL string `json:"gitea_url"` + AdminUsername string `json:"admin_username"` + AdminPassword string `json:"admin_password"` + // AdminLoginName / AdminSourceID are echoed into the admin edit call used by + // rotate-root; Gitea requires login_name on EditUserOption. For a local user + // login_name is the username and source_id is 0. + AdminLoginName string `json:"admin_login_name"` + AdminSourceID int64 `json:"admin_source_id"` + CACert string `json:"ca_cert"` + TLSSkipVerify bool `json:"tls_skip_verify"` + RequestTimeoutSeconds int `json:"request_timeout_seconds"` +} + +// loginName resolves the login_name to send to Gitea's admin edit API, falling +// back to the admin username for local users. +func (c *giteaConfig) loginName() string { + if c.AdminLoginName != "" { + return c.AdminLoginName + } + return c.AdminUsername +} + +func pathConfig(b *giteaBackend) *framework.Path { + return &framework.Path{ + Pattern: "config", + DisplayAttrs: &framework.DisplayAttributes{ + OperationPrefix: "gitea", + OperationSuffix: "config", + }, + Fields: map[string]*framework.FieldSchema{ + "gitea_url": { + Type: framework.TypeString, + Description: "Base URL of the Gitea server, e.g. https://git.example.com.", + Required: true, + }, + "admin_username": { + Type: framework.TypeString, + Description: "Username of the Gitea site admin whose Basic-Auth credentials the engine uses to mint and delete tokens for other users.", + Required: true, + }, + "admin_password": { + Type: framework.TypeString, + Description: "Password of the Gitea site admin (Basic Auth). Write-only; rotate it in place with config/rotate-root.", + DisplayAttrs: &framework.DisplayAttributes{ + Name: "Admin Password", + Sensitive: true, + }, + }, + "admin_login_name": { + Type: framework.TypeString, + Description: "login_name sent to Gitea's admin edit API during rotate-root (defaults to admin_username; use the external login name for non-local admins).", + }, + "admin_source_id": { + Type: framework.TypeInt, + Description: "Authentication source ID of the admin user, sent during rotate-root (0 for local users).", + Default: 0, + }, + "ca_cert": { + Type: framework.TypeString, + Description: "PEM CA certificate that signed the Gitea server's TLS certificate.", + }, + "tls_skip_verify": { + Type: framework.TypeBool, + Description: "Skip TLS verification of the Gitea server (not recommended).", + Default: false, + }, + "request_timeout_seconds": { + Type: framework.TypeInt, + Description: "HTTP timeout in seconds for calls to Gitea (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 Gitea and the seeded admin credentials.", + HelpDescription: "Configure the Gitea URL, TLS settings, and the site-admin username/password the engine authenticates with. Roles then mint per-user tokens with these credentials.", + } +} + +func (b *giteaBackend) 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 *giteaBackend) 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 + } + // admin_password is deliberately never returned. + return &logical.Response{ + Data: map[string]interface{}{ + "gitea_url": config.GiteaURL, + "admin_username": config.AdminUsername, + "admin_login_name": config.AdminLoginName, + "admin_source_id": config.AdminSourceID, + "tls_skip_verify": config.TLSSkipVerify, + "request_timeout_seconds": config.RequestTimeoutSeconds, + }, + }, nil +} + +func (b *giteaBackend) pathConfigWrite(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + b.lock.Lock() + defer b.lock.Unlock() + + 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 = &giteaConfig{} + } + + if v, ok := data.GetOk("gitea_url"); ok { + config.GiteaURL = v.(string) + } + if v, ok := data.GetOk("admin_username"); ok { + config.AdminUsername = v.(string) + } + if v, ok := data.GetOk("admin_password"); ok { + config.AdminPassword = v.(string) + } + if v, ok := data.GetOk("admin_login_name"); ok { + config.AdminLoginName = v.(string) + } + if v, ok := data.GetOk("admin_source_id"); ok { + config.AdminSourceID = int64(v.(int)) + } + if v, ok := data.GetOk("ca_cert"); ok { + config.CACert = v.(string) + } + if v, ok := data.GetOk("tls_skip_verify"); ok { + config.TLSSkipVerify = v.(bool) + } + if v, ok := data.GetOk("request_timeout_seconds"); ok { + config.RequestTimeoutSeconds = v.(int) + } else if req.Operation == logical.CreateOperation { + config.RequestTimeoutSeconds = data.Get("request_timeout_seconds").(int) + } + + if config.GiteaURL == "" { + return logical.ErrorResponse("gitea_url is required"), nil + } + if config.AdminUsername == "" { + return logical.ErrorResponse("admin_username is required"), nil + } + if config.AdminPassword == "" { + return logical.ErrorResponse("admin_password is required"), nil + } + + // Verify the seeded credentials authenticate and are a site admin before + // storing them, so misconfiguration fails fast rather than at first mint. + client, err := newClient(config) + if err != nil { + return logical.ErrorResponse(err.Error()), nil + } + if err := client.VerifyAdmin(ctx); err != nil { + return logical.ErrorResponse("verifying gitea admin credentials: %s", err), nil + } + + return nil, setJSON(ctx, req.Storage, configStoragePath, config) +} + +func (b *giteaBackend) pathConfigDelete(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) { + return nil, req.Storage.Delete(ctx, configStoragePath) +} + +func getConfig(ctx context.Context, s logical.Storage) (*giteaConfig, error) { + entry, err := s.Get(ctx, configStoragePath) + if err != nil { + return nil, err + } + if entry == nil { + return nil, nil + } + config := &giteaConfig{} + if err := entry.DecodeJSON(config); err != nil { + return nil, err + } + return config, nil +} + +// setJSON stores a value as a JSON storage entry. +func setJSON(ctx context.Context, s logical.Storage, key string, value interface{}) error { + entry, err := logical.StorageEntryJSON(key, value) + if err != nil { + return err + } + return s.Put(ctx, entry) +} diff --git a/path_config_rotate.go b/path_config_rotate.go new file mode 100644 index 0000000..10c0a67 --- /dev/null +++ b/path_config_rotate.go @@ -0,0 +1,101 @@ +package gitea + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + + "github.com/hashicorp/vault/sdk/framework" + "github.com/hashicorp/vault/sdk/logical" +) + +// rotatedPasswordBytes is the entropy of a generated admin password (base64 of +// this many bytes, well within Gitea's 255-char and complexity limits). +const rotatedPasswordBytes = 32 + +func pathConfigRotateRoot(b *giteaBackend) *framework.Path { + return &framework.Path{ + Pattern: "config/rotate-root", + DisplayAttrs: &framework.DisplayAttributes{ + OperationPrefix: "gitea", + OperationSuffix: "rotate-root", + }, + Operations: map[logical.Operation]framework.OperationHandler{ + logical.UpdateOperation: &framework.PathOperation{Callback: b.pathConfigRotateRoot}, + }, + HelpSynopsis: "Rotate the seeded Gitea admin password.", + HelpDescription: ` +Generates a new random password for the seeded admin user, sets it via Gitea's +admin edit API using the current credentials, and stores it. After this the old +password no longer works and only Vault knows the new one. + +Requirements and limits (documented honestly): + - The admin must be a *local* Gitea user; external-auth users cannot have + their password changed this way. + - The admin account must not have TOTP/2FA enabled, because the engine + authenticates with Basic Auth. + - If persisting the new password fails, the engine attempts to roll the + password back to the previous value. Should both the write and the rollback + fail, the admin password must be reset manually and re-seeded via config. +`, + } +} + +func (b *giteaBackend) pathConfigRotateRoot(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) { + b.lock.Lock() + defer b.lock.Unlock() + + config, err := getConfig(ctx, req.Storage) + if err != nil { + return nil, err + } + if config == nil { + return nil, errBackendNotConfigured + } + + client, err := newClient(config) + if err != nil { + return nil, err + } + + newPassword, err := generatePassword() + if err != nil { + return nil, fmt.Errorf("generating new password: %w", err) + } + + // Change the password on Gitea first; nothing is stored until this succeeds, + // so a failure here leaves the current credentials intact. + if err := client.SetAdminPassword(ctx, config.AdminUsername, config.loginName(), config.AdminSourceID, newPassword); err != nil { + return nil, fmt.Errorf("rotating admin password on gitea: %w", err) + } + + oldPassword := config.AdminPassword + config.AdminPassword = newPassword + if err := setJSON(ctx, req.Storage, configStoragePath, config); err != nil { + // The live password is now the new one but it is unstored — Vault would + // be locked out. Roll Gitea back to the old password using the new one. + rollbackClient := client.withPassword(newPassword) + if rbErr := rollbackClient.SetAdminPassword(ctx, config.AdminUsername, config.loginName(), config.AdminSourceID, oldPassword); rbErr != nil { + return nil, fmt.Errorf("CRITICAL: persisting rotated password failed (%v) and rollback failed (%v); reset the gitea admin password manually and re-seed config", err, rbErr) + } + return nil, fmt.Errorf("persisting rotated password failed, rolled back to previous password: %w", err) + } + + return &logical.Response{ + Data: map[string]interface{}{ + "admin_username": config.AdminUsername, + "rotated": true, + }, + }, nil +} + +// generatePassword returns a URL-safe base64 password with rotatedPasswordBytes +// of entropy. +func generatePassword() (string, error) { + buf := make([]byte, rotatedPasswordBytes) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} diff --git a/path_creds.go b/path_creds.go new file mode 100644 index 0000000..ef98c07 --- /dev/null +++ b/path_creds.go @@ -0,0 +1,114 @@ +package gitea + +import ( + "context" + "fmt" + "time" + + "github.com/hashicorp/go-uuid" + "github.com/hashicorp/vault/sdk/framework" + "github.com/hashicorp/vault/sdk/logical" +) + +func pathCredentials(b *giteaBackend) *framework.Path { + return &framework.Path{ + Pattern: "creds/" + framework.GenericNameRegex("name"), + DisplayAttrs: &framework.DisplayAttributes{ + OperationPrefix: "gitea", + OperationSuffix: "credentials", + }, + Fields: map[string]*framework.FieldSchema{ + "name": { + Type: framework.TypeLowerCaseString, + Description: "Name of the role to mint a token for.", + Required: true, + }, + }, + Operations: map[logical.Operation]framework.OperationHandler{ + logical.ReadOperation: &framework.PathOperation{Callback: b.pathCredentialsRead}, + logical.UpdateOperation: &framework.PathOperation{Callback: b.pathCredentialsRead}, + }, + HelpSynopsis: "Mint a short-lived Gitea token from a role.", + HelpDescription: "Reading this path mints a new, lease-bound Gitea access token for the role's user; the token is deleted from Gitea when the lease is revoked.", + } +} + +func (b *giteaBackend) pathCredentialsRead(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + roleName := data.Get("name").(string) + + // Read lock: allow concurrent mints, but block while root rotation holds the + // write lock so a mint never uses a password being changed out from under it. + b.lock.RLock() + defer b.lock.RUnlock() + + 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 + } + + config, err := getConfig(ctx, req.Storage) + if err != nil { + return nil, err + } + if config == nil { + return nil, errBackendNotConfigured + } + + client, err := newClient(config) + if err != nil { + return nil, err + } + + ttl, maxTTL := b.resolveTTLs(role.TTL, role.MaxTTL) + + suffix, err := uuid.GenerateUUID() + if err != nil { + return nil, fmt.Errorf("generating token name suffix: %w", err) + } + tokenName := fmt.Sprintf("%s-%s-%s", role.tokenNamePrefix(), roleName, suffix[:8]) + + value, id, err := client.CreateToken(ctx, role.Username, tokenName, role.Scopes) + if err != nil { + return nil, fmt.Errorf("minting gitea token: %w", err) + } + + internal := map[string]interface{}{ + "token_id": id, + "token_name": tokenName, + "username": role.Username, + } + external := map[string]interface{}{ + "token": value, + "token_id": id, + "token_name": tokenName, + "username": role.Username, + "gitea_url": config.GiteaURL, + "scopes": role.Scopes, + } + + resp := b.Secret(giteaTokenType).Response(external, internal) + resp.Secret.TTL = ttl + resp.Secret.MaxTTL = maxTTL + resp.Secret.Renewable = true + return resp, nil +} + +// resolveTTLs clamps a role's TTL/MaxTTL against the mount and system limits. +func (b *giteaBackend) resolveTTLs(roleTTL, roleMaxTTL time.Duration) (ttl, maxTTL time.Duration) { + sysMaxTTL := b.System().MaxLeaseTTL() + maxTTL = roleMaxTTL + if maxTTL <= 0 || maxTTL > sysMaxTTL { + maxTTL = sysMaxTTL + } + ttl = roleTTL + if ttl <= 0 { + ttl = b.System().DefaultLeaseTTL() + } + if ttl > maxTTL { + ttl = maxTTL + } + return ttl, maxTTL +} diff --git a/path_roles.go b/path_roles.go new file mode 100644 index 0000000..ed1c6b5 --- /dev/null +++ b/path_roles.go @@ -0,0 +1,201 @@ +package gitea + +import ( + "context" + "errors" + "time" + + "github.com/hashicorp/vault/sdk/framework" + "github.com/hashicorp/vault/sdk/logical" +) + +const roleStoragePrefix = "role/" + +// defaultTokenNamePrefix prefixes the Gitea token name of every minted token, so +// leaked/orphaned tokens are recognisable in Gitea's UI. +const defaultTokenNamePrefix = "vault" + +// giteaRole binds a Gitea user and a scope set to a TTL policy. Each read of +// creds/ mints a unique, lease-bound token for Username with Scopes. +type giteaRole struct { + // Username is the Gitea user the minted tokens belong to (a bot account). + Username string `json:"username"` + // Scopes are the Gitea access-token scopes granted to minted tokens. + Scopes []string `json:"scopes"` + // TokenNamePrefix prefixes each minted token's Gitea name. + TokenNamePrefix string `json:"token_name_prefix"` + TTL time.Duration `json:"ttl"` + MaxTTL time.Duration `json:"max_ttl"` +} + +func (r *giteaRole) tokenNamePrefix() string { + if r.TokenNamePrefix != "" { + return r.TokenNamePrefix + } + return defaultTokenNamePrefix +} + +func pathRole(b *giteaBackend) *framework.Path { + return &framework.Path{ + Pattern: "roles/" + framework.GenericNameRegex("name"), + DisplayAttrs: &framework.DisplayAttributes{ + OperationPrefix: "gitea", + OperationSuffix: "role", + }, + Fields: map[string]*framework.FieldSchema{ + "name": { + Type: framework.TypeLowerCaseString, + Description: "Name of the role.", + Required: true, + }, + "username": { + Type: framework.TypeString, + Description: "Gitea username that minted tokens belong to. The seeded admin mints tokens for this user.", + Required: true, + }, + "scopes": { + Type: framework.TypeCommaStringSlice, + Description: "Gitea access-token scopes granted to minted tokens (e.g. read:repository,write:issue). Validated against Gitea's scope set.", + Required: true, + }, + "token_name_prefix": { + Type: framework.TypeString, + Description: "Prefix for the Gitea token name of each minted token (default \"vault\").", + Default: defaultTokenNamePrefix, + }, + "ttl": { + Type: framework.TypeDurationSecond, + Description: "Default lease TTL for tokens minted from this role.", + }, + "max_ttl": { + Type: framework.TypeDurationSecond, + Description: "Maximum lease TTL for tokens minted from this role.", + }, + }, + Operations: map[logical.Operation]framework.OperationHandler{ + logical.ReadOperation: &framework.PathOperation{Callback: b.pathRoleRead}, + logical.CreateOperation: &framework.PathOperation{Callback: b.pathRoleWrite}, + logical.UpdateOperation: &framework.PathOperation{Callback: b.pathRoleWrite}, + logical.DeleteOperation: &framework.PathOperation{Callback: b.pathRoleDelete}, + }, + ExistenceCheck: b.pathRoleExistenceCheck, + HelpSynopsis: "Manage roles that mint short-lived Gitea tokens.", + HelpDescription: "Each read of creds/ mints a unique, lease-bound Gitea access token for the role's user with the role's scopes.", + } +} + +func pathRolesList(b *giteaBackend) *framework.Path { + return &framework.Path{ + Pattern: "roles/?$", + DisplayAttrs: &framework.DisplayAttributes{ + OperationPrefix: "gitea", + OperationSuffix: "roles", + }, + Operations: map[logical.Operation]framework.OperationHandler{ + logical.ListOperation: &framework.PathOperation{Callback: b.pathRolesList}, + }, + HelpSynopsis: "List roles.", + HelpDescription: "List the token-minting roles configured on this backend.", + } +} + +func (b *giteaBackend) 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 *giteaBackend) pathRoleRead(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + role, err := b.getRole(ctx, req.Storage, data.Get("name").(string)) + if err != nil { + return nil, err + } + if role == nil { + return nil, nil + } + return &logical.Response{ + Data: map[string]interface{}{ + "username": role.Username, + "scopes": role.Scopes, + "token_name_prefix": role.tokenNamePrefix(), + "ttl": int64(role.TTL.Seconds()), + "max_ttl": int64(role.MaxTTL.Seconds()), + }, + }, nil +} + +func (b *giteaBackend) pathRoleWrite(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + name := data.Get("name").(string) + role, err := b.getRole(ctx, req.Storage, name) + if err != nil { + return nil, err + } + isCreate := role == nil + if isCreate { + role = &giteaRole{} + } + + if v, ok := data.GetOk("username"); ok { + role.Username = v.(string) + } + if v, ok := data.GetOk("scopes"); ok { + scopes, serr := normalizeScopes(v.([]string)) + if serr != nil { + return logical.ErrorResponse(serr.Error()), nil + } + role.Scopes = scopes + } + if v, ok := data.GetOk("token_name_prefix"); ok { + role.TokenNamePrefix = v.(string) + } + if v, ok := data.GetOk("ttl"); ok { + role.TTL = time.Duration(v.(int)) * time.Second + } + if v, ok := data.GetOk("max_ttl"); ok { + role.MaxTTL = time.Duration(v.(int)) * time.Second + } + + if role.Username == "" { + return logical.ErrorResponse("username is required"), nil + } + if len(role.Scopes) == 0 { + return logical.ErrorResponse("at least one scope is required"), nil + } + if role.MaxTTL > 0 && role.TTL > role.MaxTTL { + return logical.ErrorResponse("ttl must not exceed max_ttl"), nil + } + + return nil, setJSON(ctx, req.Storage, roleStoragePrefix+name, role) +} + +func (b *giteaBackend) pathRoleDelete(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + return nil, req.Storage.Delete(ctx, roleStoragePrefix+data.Get("name").(string)) +} + +func (b *giteaBackend) 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 *giteaBackend) getRole(ctx context.Context, s logical.Storage, name string) (*giteaRole, error) { + if name == "" { + return nil, errors.New("missing role name") + } + entry, err := s.Get(ctx, roleStoragePrefix+name) + if err != nil { + return nil, err + } + if entry == nil { + return nil, nil + } + role := &giteaRole{} + if err := entry.DecodeJSON(role); err != nil { + return nil, err + } + return role, nil +} diff --git a/scopes.go b/scopes.go new file mode 100644 index 0000000..7bff8be --- /dev/null +++ b/scopes.go @@ -0,0 +1,73 @@ +package gitea + +import ( + "fmt" + "sort" + "strings" +) + +// validScopes is the authoritative set of Gitea access-token scopes, matching +// go-gitea/gitea models/auth/access_token_scope.go. "all" grants every +// permission; "public-only" restricts a token to public resources. Every +// category has read: and write: forms (write implies read). +var validScopes = map[string]struct{}{ + "all": {}, + "public-only": {}, + "read:activitypub": {}, + "write:activitypub": {}, + "read:admin": {}, + "write:admin": {}, + "read:misc": {}, + "write:misc": {}, + "read:notification": {}, + "write:notification": {}, + "read:organization": {}, + "write:organization": {}, + "read:package": {}, + "write:package": {}, + "read:issue": {}, + "write:issue": {}, + "read:repository": {}, + "write:repository": {}, + "read:user": {}, + "write:user": {}, +} + +// knownScopes returns the sorted list of valid scopes, for error messages. +func knownScopes() []string { + out := make([]string, 0, len(validScopes)) + for s := range validScopes { + out = append(out, s) + } + sort.Strings(out) + return out +} + +// normalizeScopes trims, lower-cases and de-duplicates the requested scopes, +// rejecting any that Gitea would not recognise. Order is preserved (first +// occurrence wins) so the stored role reads back predictably. +func normalizeScopes(scopes []string) ([]string, error) { + if len(scopes) == 0 { + return nil, fmt.Errorf("at least one scope is required; valid scopes: %s", strings.Join(knownScopes(), ", ")) + } + seen := make(map[string]struct{}, len(scopes)) + out := make([]string, 0, len(scopes)) + for _, raw := range scopes { + s := strings.ToLower(strings.TrimSpace(raw)) + if s == "" { + continue + } + if _, ok := validScopes[s]; !ok { + return nil, fmt.Errorf("invalid scope %q; valid scopes: %s", raw, strings.Join(knownScopes(), ", ")) + } + if _, dup := seen[s]; dup { + continue + } + seen[s] = struct{}{} + out = append(out, s) + } + if len(out) == 0 { + return nil, fmt.Errorf("at least one scope is required; valid scopes: %s", strings.Join(knownScopes(), ", ")) + } + return out, nil +} diff --git a/scopes_test.go b/scopes_test.go new file mode 100644 index 0000000..986cec6 --- /dev/null +++ b/scopes_test.go @@ -0,0 +1,53 @@ +package gitea + +import ( + "strings" + "testing" +) + +func TestNormalizeScopes(t *testing.T) { + cases := []struct { + name string + in []string + want []string + wantErr bool + }{ + {"single", []string{"read:repository"}, []string{"read:repository"}, false}, + {"trim+case", []string{" Write:Issue "}, []string{"write:issue"}, false}, + {"dedupe", []string{"read:user", "read:user", "write:user"}, []string{"read:user", "write:user"}, false}, + {"all", []string{"all"}, []string{"all"}, false}, + {"public-only", []string{"public-only", "read:repository"}, []string{"public-only", "read:repository"}, false}, + {"blank-only", []string{"", " "}, nil, true}, + {"empty", nil, nil, true}, + {"invalid", []string{"read:repository", "sudo"}, nil, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := normalizeScopes(c.in) + if c.wantErr { + if err == nil { + t.Fatalf("expected error, got %v", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Join(got, ",") != strings.Join(c.want, ",") { + t.Errorf("normalizeScopes(%v) = %v, want %v", c.in, got, c.want) + } + }) + } +} + +func TestKnownScopesSorted(t *testing.T) { + scopes := knownScopes() + if len(scopes) != len(validScopes) { + t.Fatalf("knownScopes len = %d, want %d", len(scopes), len(validScopes)) + } + for i := 1; i < len(scopes); i++ { + if scopes[i-1] > scopes[i] { + t.Errorf("knownScopes not sorted at %d: %q > %q", i, scopes[i-1], scopes[i]) + } + } +} diff --git a/scripts/build-rpm.sh b/scripts/build-rpm.sh new file mode 100755 index 0000000..9457626 --- /dev/null +++ b/scripts/build-rpm.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# +# Package the (already built) plugin binary into RPMs with nfpm. Builds one RPM +# per target server: Vault (/opt/vault-plugins) and OpenBao (/opt/openbao-plugins). +# Usage: scripts/build-rpm.sh [version] (version defaults to $CI_COMMIT_TAG) +# +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${ROOT_DIR}" + +VERSION="${1:-${CI_COMMIT_TAG:-0.0.0-dev}}" +VERSION="${VERSION#v}" +BINARY="vault-plugin-secrets-gitea" +DIST="dist" + +if [ ! -f "${DIST}/${BINARY}" ]; then + echo "ERROR: ${DIST}/${BINARY} not found; run 'make build' first" >&2 + exit 1 +fi + +export PACKAGE_VERSION="${VERSION}" +export PACKAGE_RELEASE="1" +export PACKAGE_ARCH="amd64" +export PACKAGE_PLATFORM="linux" +export PACKAGE_DESCRIPTION="Vault/OpenBao secrets engine for ephemeral, scoped Gitea access tokens" +export PACKAGE_MAINTAINER="Ben Vincent " +export PACKAGE_HOMEPAGE="https://git.unkin.net/unkin/vault-plugin-secrets-gitea" +export PACKAGE_LICENSE="MIT" + +build_flavor() { + export PACKAGE_NAME="$1" + export PACKAGE_PLUGIN_DIR="$2" + export PACKAGE_PREINSTALL="${DIST}/preinstall-${PACKAGE_NAME}.sh" + envsubst '${PACKAGE_PLUGIN_DIR}' < packaging/scripts/preinstall.sh.tmpl > "${PACKAGE_PREINSTALL}" + envsubst < packaging/nfpm.yaml > "${DIST}/nfpm-${PACKAGE_NAME}.yaml" + nfpm pkg --config "${DIST}/nfpm-${PACKAGE_NAME}.yaml" --target "${DIST}" --packager rpm +} + +build_flavor "vault-plugin-secrets-gitea" "/opt/vault-plugins" +build_flavor "openbao-plugin-secrets-gitea" "/opt/openbao-plugins" + +echo "Built:" +ls -1 "${DIST}"/*.rpm diff --git a/scripts/e2e.sh b/scripts/e2e.sh new file mode 100755 index 0000000..d8e7e28 --- /dev/null +++ b/scripts/e2e.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# +# End-to-end test for vault-plugin-secrets-gitea. +# +# Builds the plugin, brings up a mock Gitea REST API plus both Vault and OpenBao, +# then drives the identical lifecycle against each engine to prove the same +# binary works on both: +# configure -> rotate-root -> role -> creds (mint) -> revoke. +# +# Select engines with ENGINES (default "vault openbao"), e.g. ENGINES=openbao. +# +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +COMPOSE_FILE="${ROOT_DIR}/test/docker-compose.yml" +COMPOSE="docker compose -f ${COMPOSE_FILE}" +BINARY="vault-plugin-secrets-gitea" + +ADMIN_USER="bot-admin" +ADMIN_PASS="seed-password" +MOUNT="gitea" +ENGINES="${ENGINES:-vault openbao}" + +red() { printf '\033[31m%s\033[0m\n' "$*"; } +green() { printf '\033[32m%s\033[0m\n' "$*"; } +blue() { printf '\033[34m==> %s\033[0m\n' "$*"; } + +cleanup() { blue "Tearing down containers"; ${COMPOSE} down -v >/dev/null 2>&1 || true; } +trap cleanup EXIT +fail() { red "FAIL: $*"; exit 1; } + +wait_for() { + local desc="$1"; shift + local i=0 + until "$@" >/dev/null 2>&1; do + i=$((i + 1)) + [ "$i" -ge "${WAIT_RETRIES:-90}" ] && fail "timed out waiting for ${desc}" + sleep 2 + done + green "ready: ${desc}" +} + +jq_field() { python3 -c "import sys,json;print(json.load(sys.stdin)$1)"; } + +run_engine() { + local engine="$1" container="$2" cli="$3" + blue "[${engine}] exercising the plugin" + ex() { ${COMPOSE} exec -T "${container}" "${cli}" "$@"; } + + local sha; sha="$(sha256sum "${ROOT_DIR}/dist/${BINARY}" | awk '{print $1}')" + ex plugin register -sha256="${sha}" secret "${BINARY}" >/dev/null || true + ex secrets disable "${MOUNT}" >/dev/null 2>&1 || true + ex secrets enable -path="${MOUNT}" "${BINARY}" >/dev/null + green "[${engine}] plugin registered and mounted" + + # The plugin runs inside the engine container, so it reaches gitea by name. + ex write "${MOUNT}/config" gitea_url="http://gitea:3000" \ + admin_username="${ADMIN_USER}" admin_password="${ADMIN_PASS}" >/dev/null + green "[${engine}] configured" + + # --- rotate the seeded admin password; subsequent mints must still work --- + ex write -f "${MOUNT}/config/rotate-root" >/dev/null + green "[${engine}] rotated root password" + + # --- role + dynamic creds --- + ex write "${MOUNT}/roles/teabot" username="teabot" \ + scopes="read:repository,write:issue" ttl=1h max_ttl=24h >/dev/null + local json id lease tok user + json="$(ex read -format=json "${MOUNT}/creds/teabot")" + id="$(printf '%s' "${json}" | jq_field '["data"]["token_id"]')" + lease="$(printf '%s' "${json}" | jq_field '["lease_id"]')" + tok="$(printf '%s' "${json}" | jq_field '["data"]["token"]')" + user="$(printf '%s' "${json}" | jq_field '["data"]["username"]')" + [ -n "${id}" ] || fail "[${engine}] no dynamic token id returned" + [ -n "${tok}" ] || fail "[${engine}] dynamic creds returned empty token value" + [ "${user}" = "teabot" ] || fail "[${engine}] wrong username ${user}" + green "[${engine}] dynamic token id=${id} issued for ${user} (lease ${lease})" + + # revoke -> token deleted from gitea (idempotent: a second revoke is a no-op) + ex lease revoke "${lease}" >/dev/null + green "[${engine}] revoked lease ${lease}" + + green "[${engine}] PASSED" +} + +blue "Building plugin for linux/amd64" +OS=linux ARCH=amd64 PLUGIN_DIR="${ROOT_DIR}/dist" make -C "${ROOT_DIR}" build + +blue "Starting Docker stack (gitea + vault + openbao)" +${COMPOSE} up -d --build + +wait_for "gitea" curl -fsS "http://127.0.0.1:3000/healthz" + +for engine in ${ENGINES}; do + case "${engine}" in + vault) wait_for "vault" ${COMPOSE} exec -T vault vault status -address=http://127.0.0.1:8200; run_engine vault vault vault ;; + openbao) wait_for "openbao" ${COMPOSE} exec -T openbao bao status -address=http://127.0.0.1:8200; run_engine openbao openbao bao ;; + *) fail "unknown engine: ${engine}" ;; + esac +done + +green "ALL END-TO-END CHECKS PASSED (${ENGINES})" diff --git a/secret_token.go b/secret_token.go new file mode 100644 index 0000000..03f2fe5 --- /dev/null +++ b/secret_token.go @@ -0,0 +1,83 @@ +package gitea + +import ( + "context" + "errors" + "fmt" + + "github.com/hashicorp/vault/sdk/framework" + "github.com/hashicorp/vault/sdk/logical" +) + +const giteaTokenType = "gitea_token" + +func (b *giteaBackend) giteaTokenSecret() *framework.Secret { + return &framework.Secret{ + Type: giteaTokenType, + Fields: map[string]*framework.FieldSchema{ + "token": { + Type: framework.TypeString, + Description: "The Gitea access token value.", + }, + "token_id": { + Type: framework.TypeString, + Description: "The Gitea access token id (used for deletion).", + }, + "token_name": { + Type: framework.TypeString, + Description: "The Gitea access token name.", + }, + "username": { + Type: framework.TypeString, + Description: "The Gitea user the token belongs to.", + }, + }, + Revoke: b.secretRevoke, + Renew: b.secretRenew, + } +} + +// secretRevoke deletes the minted Gitea token via the admin API. Returning an +// error lets Vault retry revocation; a token that is already gone (404) is +// treated as success inside DeleteToken so retries converge. +func (b *giteaBackend) secretRevoke(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) { + tokenID, err := internalString(req.Secret.InternalData, "token_id") + if err != nil { + return nil, err + } + username, err := internalString(req.Secret.InternalData, "username") + if err != nil { + return nil, err + } + + b.lock.RLock() + defer b.lock.RUnlock() + + client, err := b.clientFor(ctx, req.Storage) + if err != nil { + return nil, err + } + if err := client.DeleteToken(ctx, username, tokenID); err != nil { + return nil, fmt.Errorf("revoking gitea token %q for user %q: %w", tokenID, username, err) + } + return nil, nil +} + +// secretRenew extends the Vault lease; the token material is unchanged. Gitea +// tokens never expire server-side, so the lease alone bounds the lifetime and a +// renew within max_ttl simply postpones deletion. +func (b *giteaBackend) secretRenew(_ context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) { + return &logical.Response{Secret: req.Secret}, nil +} + +func internalString(data map[string]interface{}, key string) (string, error) { + raw, ok := data[key] + if !ok { + return "", fmt.Errorf("secret is missing internal %s data", key) + } + s, ok := raw.(string) + if !ok { + return "", errors.New("secret internal " + key + " data is not a string") + } + return s, nil +} diff --git a/test/docker-compose.yml b/test/docker-compose.yml new file mode 100644 index 0000000..b231693 --- /dev/null +++ b/test/docker-compose.yml @@ -0,0 +1,64 @@ +# End-to-end test stack. A mock Gitea REST API (in-memory, no db/git) plus two +# secrets-engine hosts running the exact same plugin binary: HashiCorp Vault and +# OpenBao. Bind mounts use ":z" so they work under SELinux. +services: + gitea: + image: golang:1.25-alpine + working_dir: /src + environment: + MOCKGITEA_ADDR: ":3000" + MOCKGITEA_ADMIN_USER: "bot-admin" + MOCKGITEA_ADMIN_PASS: "seed-password" + GOFLAGS: "-mod=mod" + command: ["go", "run", "./test/mockgitea"] + volumes: + - ..:/src:ro,z + ports: + - "3000:3000" + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:3000/healthz"] + interval: 3s + timeout: 3s + retries: 40 + + vault: + image: hashicorp/vault:1.18 + depends_on: + gitea: + condition: service_healthy + cap_add: [IPC_LOCK] + environment: + VAULT_DEV_ROOT_TOKEN_ID: root + VAULT_ADDR: http://127.0.0.1:8200 + VAULT_TOKEN: root + command: ["server", "-dev", "-dev-listen-address=0.0.0.0:8200", "-config=/vault/vault.hcl"] + volumes: + - ../dist:/vault/plugins:ro,z + - ./vault/vault.hcl:/vault/vault.hcl:ro,z + ports: ["8200:8200"] + healthcheck: + test: ["CMD", "vault", "status", "-address=http://127.0.0.1:8200"] + interval: 3s + timeout: 3s + retries: 20 + + openbao: + image: openbao/openbao:latest + depends_on: + gitea: + condition: service_healthy + cap_add: [IPC_LOCK] + environment: + BAO_DEV_ROOT_TOKEN_ID: root + BAO_ADDR: http://127.0.0.1:8200 + BAO_TOKEN: root + command: ["server", "-dev", "-dev-listen-address=0.0.0.0:8200", "-config=/openbao/bao.hcl"] + volumes: + - ../dist:/openbao/plugins:ro,z + - ./openbao/bao.hcl:/openbao/bao.hcl:ro,z + ports: ["8300:8200"] + healthcheck: + test: ["CMD", "bao", "status", "-address=http://127.0.0.1:8200"] + interval: 3s + timeout: 3s + retries: 20 diff --git a/test/mockgitea/main.go b/test/mockgitea/main.go new file mode 100644 index 0000000..70b1739 --- /dev/null +++ b/test/mockgitea/main.go @@ -0,0 +1,168 @@ +// Command mockgitea is an in-memory stand-in for the subset of the Gitea REST +// API that vault-plugin-secrets-gitea uses: whoami, per-user access token +// create/delete, and admin password change. It is used by the e2e tests — no +// real Gitea, database, or git required. Not for production use. +// +// Authentication is HTTP Basic Auth against the *current* admin credentials +// (MOCKGITEA_ADMIN_USER / MOCKGITEA_ADMIN_PASS); a successful admin password +// change updates the accepted credentials, exactly as the plugin's rotate-root +// relies on. +package main + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "log" + "net/http" + "os" + "strconv" + "strings" + "sync" +) + +type createTokenOption struct { + Name string `json:"name"` + Scopes []string `json:"scopes"` +} + +type editUserOption struct { + LoginName string `json:"login_name"` + SourceID int64 `json:"source_id"` + Password string `json:"password"` +} + +type store struct { + mu sync.Mutex + adminU string + adminP string + nextID int64 + tokens map[string]bool // key: username/id +} + +func randHex(n int) string { + buf := make([]byte, n) + _, _ = rand.Read(buf) + return hex.EncodeToString(buf) +} + +func (s *store) authOK(r *http.Request) bool { + u, p, ok := r.BasicAuth() + if !ok { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + return u == s.adminU && p == s.adminP +} + +func writeJSON(w http.ResponseWriter, code int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} + +func (s *store) handleUser(w http.ResponseWriter, r *http.Request) { + if !s.authOK(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + s.mu.Lock() + login := s.adminU + s.mu.Unlock() + writeJSON(w, http.StatusOK, map[string]interface{}{"login": login, "is_admin": true}) +} + +func (s *store) handleUsers(w http.ResponseWriter, r *http.Request) { + if !s.authOK(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + // /api/v1/users/{username}/tokens[/{id}] + rest := strings.TrimPrefix(r.URL.Path, "/api/v1/users/") + switch { + case r.Method == http.MethodPost && strings.HasSuffix(rest, "/tokens"): + username := strings.TrimSuffix(rest, "/tokens") + var in createTokenOption + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + s.mu.Lock() + s.nextID++ + id := s.nextID + s.tokens[username+"/"+strconv.FormatInt(id, 10)] = true + s.mu.Unlock() + writeJSON(w, http.StatusCreated, map[string]interface{}{ + "id": id, + "name": in.Name, + "sha1": randHex(20), + "token_last_eight": randHex(4), + "scopes": in.Scopes, + }) + case r.Method == http.MethodDelete && strings.Contains(rest, "/tokens/"): + parts := strings.SplitN(rest, "/tokens/", 2) + if len(parts) != 2 { + w.WriteHeader(http.StatusNotFound) + return + } + k := parts[0] + "/" + parts[1] + s.mu.Lock() + exists := s.tokens[k] + delete(s.tokens, k) + s.mu.Unlock() + if !exists { + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusNoContent) + default: + w.WriteHeader(http.StatusNotFound) + } +} + +func (s *store) handleAdminUsers(w http.ResponseWriter, r *http.Request) { + if !s.authOK(r) { + w.WriteHeader(http.StatusUnauthorized) + return + } + if r.Method != http.MethodPatch { + w.WriteHeader(http.StatusNotFound) + return + } + var in editUserOption + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + s.mu.Lock() + if in.Password != "" { + s.adminP = in.Password + } + s.mu.Unlock() + w.WriteHeader(http.StatusOK) +} + +func main() { + addr := os.Getenv("MOCKGITEA_ADDR") + if addr == "" { + addr = ":3000" + } + adminU := os.Getenv("MOCKGITEA_ADMIN_USER") + if adminU == "" { + adminU = "bot-admin" + } + adminP := os.Getenv("MOCKGITEA_ADMIN_PASS") + if adminP == "" { + adminP = "seed-password" + } + s := &store{adminU: adminU, adminP: adminP, tokens: map[string]bool{}} + + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/user", s.handleUser) + mux.HandleFunc("/api/v1/users/", s.handleUsers) + mux.HandleFunc("/api/v1/admin/users/", s.handleAdminUsers) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) }) + log.Printf("mock gitea API listening on %s (admin %s)", addr, adminU) + log.Fatal(http.ListenAndServe(addr, mux)) //nolint:gosec // test-only mock +} diff --git a/test/openbao/bao.hcl b/test/openbao/bao.hcl new file mode 100644 index 0000000..391c032 --- /dev/null +++ b/test/openbao/bao.hcl @@ -0,0 +1,4 @@ +# OpenBao is plugin-protocol compatible with Vault, so the very same plugin +# binary registers and runs here unchanged. Combined with `-dev` at runtime. +plugin_directory = "/openbao/plugins" +api_addr = "http://127.0.0.1:8200" diff --git a/test/vault/vault.hcl b/test/vault/vault.hcl new file mode 100644 index 0000000..f848cfd --- /dev/null +++ b/test/vault/vault.hcl @@ -0,0 +1,4 @@ +# Combined with `-dev` at runtime; supplies the plugin_directory the dev server +# would otherwise leave unset, so the plugin binary in ../dist can be registered. +plugin_directory = "/vault/plugins" +api_addr = "http://127.0.0.1:8200"