Merge pull request 'Initial vault-plugin-secrets-gitea engine' (#1) from benvin/gitea-initial into main
ci/woodpecker/tag/release Pipeline was successful

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-07-27 17:11:22 +10:00
30 changed files with 2931 additions and 1 deletions
+6
View File
@@ -0,0 +1,6 @@
/dist/
/vault-plugin-secrets-gitea
*.out
*.test
.env
test/plugins/
+15
View File
@@ -0,0 +1,15 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- repo: https://github.com/dnephin/pre-commit-golang
rev: v0.5.1
hooks:
- id: go-fmt
- id: go-vet
- id: go-mod-tidy
+18
View File
@@ -0,0 +1,18 @@
when:
- event: pull_request
steps:
- name: build
image: golang:1.25
commands:
- make build
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+18
View File
@@ -0,0 +1,18 @@
when:
- event: pull_request
steps:
- name: pre-commit
image: git.unkin.net/unkin/almalinux9-gobuilder:20260606
commands:
- uvx pre-commit run --all-files
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+47
View File
@@ -0,0 +1,47 @@
when:
- event: tag
steps:
- name: build
image: git.unkin.net/unkin/almalinux9-gobuilder:20260606
commands:
- make build VERSION=${CI_COMMIT_TAG}
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests: {memory: 512Mi, cpu: 1}
limits: {memory: 2Gi, cpu: 2}
- name: package
image: git.unkin.net/unkin/almalinux9-rpmbuilder:latest
commands:
- ./scripts/build-rpm.sh ${CI_COMMIT_TAG}
depends_on: [build]
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests: {memory: 512Mi, cpu: 1}
limits: {memory: 2Gi, cpu: 2}
- name: upload
image: git.unkin.net/unkin/almalinux9-base:20260606
commands:
- |
HOST="https://artifactapi.k8s.syd1.au.unkin.net"
REPO="rpm-internal"
for rpm in dist/*.rpm; do
FILE=$$(basename "$$rpm")
code=$$(curl -s -o /dev/null -w '%{http_code}' "$$HOST/api/v2/remotes/$$REPO/files/Packages/$$FILE" || true)
if [ "$$code" = "200" ]; then echo "$$FILE exists; skipping"; continue; fi
echo "Uploading $$FILE (probe $$code)"
curl -f -X PUT "$$HOST/api/v2/remotes/$$REPO/files/$$FILE" -H "Content-Type: application/x-rpm" --data-binary @"$$rpm"
done
depends_on: [package]
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests: {memory: 128Mi, cpu: 100m}
limits: {memory: 512Mi, cpu: 500m}
+33
View File
@@ -0,0 +1,33 @@
when:
- event: pull_request
steps:
- name: lint
image: golang:1.25
commands:
- make lint
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
- name: test
image: golang:1.25
commands:
- make test
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+77
View File
@@ -0,0 +1,77 @@
.PHONY: build install test lint fmt clean tidy rpm rpm-package patch minor major check-go e2e e2e-vault e2e-openbao e2e-up e2e-down
BINARY := vault-plugin-secrets-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=<sha> secret $(BINARY))"
test: check-go
go test -race -count=1 ./...
lint: check-go
go vet ./...
fmt: check-go
gofmt -w .
tidy:
go mod tidy
clean:
rm -rf $(PLUGIN_DIR)
rpm: build rpm-package
rpm-package:
./scripts/build-rpm.sh $(VERSION)
# End-to-end tests bring up a mock 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
+132 -1
View File
@@ -1,3 +1,134 @@
# vault-plugin-secrets-gitea
HashiCorp Vault / OpenBao secrets engine for Gitea: mints ephemeral, scoped per-user access tokens via the admin API, with seeded-admin basic-auth and root password rotation
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/<role>` 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/<name>` | Mint policy: `username`, `scopes`, `ttl`, `max_ttl`, `token_name_prefix`. |
| `roles` | List roles. |
| `creds/<role>` | 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-<ver>-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.
+106
View File
@@ -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/<role>, 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/<role> 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.
`
+449
View File
@@ -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 }
+195
View File
@@ -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
}
+101
View File
@@ -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")
}
}
+34
View File
@@ -0,0 +1,34 @@
package main
import (
"os"
hclog "github.com/hashicorp/go-hclog"
"github.com/hashicorp/vault/api"
"github.com/hashicorp/vault/sdk/plugin"
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)
}
}
+90
View File
@@ -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
)
+340
View File
@@ -0,0 +1,340 @@
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA=
github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4=
github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI=
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA=
github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v26.1.5+incompatible h1:NEAxTwEjxV6VbBMBoGG3zPqbiJosIApZjxlbrG9q3/g=
github.com/docker/docker v26.1.5+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/evanphx/json-patch/v5 v5.6.0 h1:b91NhWfaz02IuVxO9faSllyAtNXHMPkC5J8sJCLunww=
github.com/evanphx/json-patch/v5 v5.6.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.0 h1:+cqqvzZV87b4adx/5ayVOaYZ2CrvM4ejQvUdBzPPUss=
github.com/frankban/quicktest v1.14.0/go.mod h1:NeW+ay9A/U67EYXNFA1nPE8e/tnQv/09mUdL/ijj8og=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.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=
+35
View File
@@ -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}
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
# Ensure the plugin directory exists before the binary is laid down.
# Rendered per flavour via envsubst (see scripts/build-rpm.sh).
mkdir -p ${PACKAGE_PLUGIN_DIR}
+220
View File
@@ -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)
}
+101
View File
@@ -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
}
+114
View File
@@ -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
}
+201
View File
@@ -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/<name> 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/<name> 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
}
+73
View File
@@ -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
}
+53
View File
@@ -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])
}
}
}
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
#
# Package the (already built) plugin binary into RPMs with nfpm. Builds one RPM
# per target server: Vault (/opt/vault-plugins) and OpenBao (/opt/openbao-plugins).
# Usage: scripts/build-rpm.sh [version] (version defaults to $CI_COMMIT_TAG)
#
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT_DIR}"
VERSION="${1:-${CI_COMMIT_TAG:-0.0.0-dev}}"
VERSION="${VERSION#v}"
BINARY="vault-plugin-secrets-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 <ben@unkin.net>"
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
Executable
+102
View File
@@ -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})"
+83
View File
@@ -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
}
+64
View File
@@ -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
+168
View File
@@ -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
}
+4
View File
@@ -0,0 +1,4 @@
# OpenBao is plugin-protocol compatible with Vault, so the very same plugin
# binary registers and runs here unchanged. Combined with `-dev` at runtime.
plugin_directory = "/openbao/plugins"
api_addr = "http://127.0.0.1:8200"
+4
View File
@@ -0,0 +1,4 @@
# Combined with `-dev` at runtime; supplies the plugin_directory the dev server
# would otherwise leave unset, so the plugin binary in ../dist can be registered.
plugin_directory = "/vault/plugins"
api_addr = "http://127.0.0.1:8200"