commit ae30ba1240fc3e837af6a139eeaef1c95f6e37e0 Author: Florian Weber Date: Fri Aug 14 11:09:03 2026 +0200 feat(vault-tui): implement KV client and service for managing secrets - Added internal/vault/client.go for creating a Vault client with configuration settings. - Introduced internal/vault/errors.go to classify Vault API errors for better UI handling. - Created internal/vault/kv.go to manage KV secrets, including listing, reading, writing, and deleting operations. - Implemented internal/vault/mounts.go to list and describe secret engine mounts. - Developed internal/vault/service.go to provide a unified entry point for Vault operations. - Added internal/vault/kv_test.go for comprehensive testing of KV operations. - Introduced internal/ui/toast.go for transient notifications in the UI. - Added renovate.json for dependency management and updates. diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..abf43e1 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,45 @@ +name: Release + +on: + release: + types: [published] + +permissions: + contents: write + +jobs: + build-and-upload: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ gitea.ref }} + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Test + run: make test + + - name: Build all artifacts (default + cloud, all platforms) + run: | + make release + make release-cloud + + - name: Upload artifacts to release + env: + GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + api="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/${{ gitea.event.release.id }}/assets" + for f in dist/*; do + name="$(basename "$f")" + echo "uploading $name" + curl --fail -sS -X POST \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -F "attachment=@${f};filename=${name}" \ + "${api}?name=${name}" + done diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..232c06c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/vault-tui +/dist/ +*.test +coverage.out +coverage.html diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3c98ddb --- /dev/null +++ b/Makefile @@ -0,0 +1,169 @@ +SHELL := /bin/sh + +MODULE := git.morlana.online/f.weber/vault-tui +PKG := ./cmd/vault-tui +BIN := vault-tui +DIST := dist +PREFIX ?= $(HOME)/.local +GOBIN ?= $(PREFIX)/bin + +VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo none) +DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ) +LDFLAGS := -s -w -X main.version=$(VERSION) +GOFLAGS := -trimpath +CGO_ENABLED ?= 0 + +TAGS ?= +CLOUD_TAGS := cloud + +PLATFORMS ?= linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 + +GOLANGCI := $(shell command -v golangci-lint 2>/dev/null) + +.PHONY: help build build-cloud all run run-cloud install install-cloud uninstall \ + test test-race test-cloud cover cover-html fmt fmt-check vet lint tidy tidy-check \ + check release release-cloud clean version + +## help: show this help +help: + @echo "vault-tui — make targets" + @echo "" + @awk 'BEGIN {FS = ":.*## "} /^[a-zA-Z0-9_-]+:.*## / {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) + @echo "" + @echo "Variables: TAGS=cloud (adds cloud auth to any target), PREFIX, GOBIN, PLATFORMS, VERSION" + +## build: build the default binary (no cloud auth) into dist/ +build: ## build the default binary (no cloud auth) into dist/ + @mkdir -p $(DIST) + CGO_ENABLED=$(CGO_ENABLED) go build $(GOFLAGS) -tags '$(TAGS)' -ldflags '$(LDFLAGS)' -o $(DIST)/$(BIN) $(PKG) + @echo "built $(DIST)/$(BIN) ($(VERSION))" + +## build-cloud: build with AWS/Azure/GCP auth included +build-cloud: ## build with AWS/Azure/GCP auth included + @$(MAKE) build TAGS="$(CLOUD_TAGS) $(TAGS)" BIN=$(BIN)-cloud + +## all: build both the default and cloud-enabled binaries +all: build build-cloud ## build both the default and cloud-enabled binaries + +## run: build and run the default binary (pass args via ARGS="...") +run: build ## build and run the default binary (pass args via ARGS="...") + @./$(DIST)/$(BIN) $(ARGS) + +## run-cloud: build and run the cloud-enabled binary (pass args via ARGS="...") +run-cloud: build-cloud ## build and run the cloud-enabled binary (pass args via ARGS="...") + @./$(DIST)/$(BIN)-cloud $(ARGS) + +## install: go install the default binary into GOBIN/PREFIX +install: ## go install the default binary into GOBIN/PREFIX + GOBIN=$(GOBIN) CGO_ENABLED=$(CGO_ENABLED) go install $(GOFLAGS) -tags '$(TAGS)' -ldflags '$(LDFLAGS)' $(PKG) + @echo "installed $(BIN) to $(GOBIN)" + +## install-cloud: go install the cloud-enabled binary into GOBIN/PREFIX +install-cloud: ## go install the cloud-enabled binary into GOBIN/PREFIX + @$(MAKE) install TAGS="$(CLOUD_TAGS) $(TAGS)" + +## uninstall: remove the installed binary from GOBIN/PREFIX +uninstall: ## remove the installed binary from GOBIN/PREFIX + rm -f $(GOBIN)/$(BIN) + +## test: run tests (default build tags) +test: ## run tests (default build tags) + go test $(GOFLAGS) -tags '$(TAGS)' ./... + +## test-race: run tests with the race detector +test-race: ## run tests with the race detector + CGO_ENABLED=1 go test $(GOFLAGS) -tags '$(TAGS)' -race ./... + +## test-cloud: run tests with cloud auth compiled in +test-cloud: ## run tests with cloud auth compiled in + @$(MAKE) test TAGS="$(CLOUD_TAGS) $(TAGS)" + +## cover: run tests and write coverage.out +cover: ## run tests and write coverage.out + go test $(GOFLAGS) -tags '$(TAGS)' -coverprofile=coverage.out ./... + go tool cover -func=coverage.out | tail -1 + +## cover-html: open an HTML coverage report from coverage.out +cover-html: cover ## open an HTML coverage report from coverage.out + go tool cover -html=coverage.out -o coverage.html + @echo "wrote coverage.html" + +## fmt: format all Go source in place +fmt: ## format all Go source in place + gofmt -l -w . + +## fmt-check: fail if any file is not gofmt-formatted +fmt-check: ## fail if any file is not gofmt-formatted + @unformatted="$$(gofmt -l .)"; \ + if [ -n "$$unformatted" ]; then \ + echo "not gofmt-formatted:"; echo "$$unformatted"; exit 1; \ + fi + +## vet: run go vet +vet: ## run go vet + go vet -tags '$(TAGS)' ./... + +## lint: run golangci-lint if installed, else fall back to go vet +lint: ## run golangci-lint if installed, else fall back to go vet +ifdef GOLANGCI + golangci-lint run ./... +else + @echo "golangci-lint not found on PATH — running go vet instead" + @echo "install: https://golangci-lint.run/welcome/install/" + @$(MAKE) vet +endif + +## tidy: run go mod tidy +tidy: ## run go mod tidy + go mod tidy + +## tidy-check: fail if go mod tidy would change go.mod/go.sum +tidy-check: ## fail if go mod tidy would change go.mod/go.sum + @cp go.mod /tmp/vault-tui-go.mod.orig; cp go.sum /tmp/vault-tui-go.sum.orig + @go mod tidy + @diff -q /tmp/vault-tui-go.mod.orig go.mod >/dev/null && diff -q /tmp/vault-tui-go.sum.orig go.sum >/dev/null \ + || (echo "go.mod/go.sum are not tidy — run 'make tidy' and commit the result" && \ + cp /tmp/vault-tui-go.mod.orig go.mod && cp /tmp/vault-tui-go.sum.orig go.sum && exit 1) + @rm -f /tmp/vault-tui-go.mod.orig /tmp/vault-tui-go.sum.orig + +## check: fmt-check + vet + lint + test — the pre-commit/CI gate +check: fmt-check vet lint test ## fmt-check + vet + lint + test — the pre-commit/CI gate + +## release: cross-compile the default binary for PLATFORMS into dist/, with archives + checksums +release: ## cross-compile the default binary for PLATFORMS into dist/, with archives + checksums + @$(MAKE) _release TAGS="$(TAGS)" SUFFIX="" + +## release-cloud: cross-compile the cloud-enabled binary for PLATFORMS into dist/ +release-cloud: ## cross-compile the cloud-enabled binary for PLATFORMS into dist/ + @$(MAKE) _release TAGS="$(CLOUD_TAGS) $(TAGS)" SUFFIX="-cloud" + +.PHONY: _release +_release: + @mkdir -p $(DIST) + @for plat in $(PLATFORMS); do \ + os=$${plat%/*}; arch=$${plat#*/}; \ + out=$(BIN)$(SUFFIX)-$(VERSION)-$$os-$$arch; \ + ext=""; [ "$$os" = "windows" ] && ext=".exe"; \ + echo "building $$out$$ext"; \ + GOOS=$$os GOARCH=$$arch CGO_ENABLED=0 go build $(GOFLAGS) -tags '$(TAGS)' -ldflags '$(LDFLAGS)' -o $(DIST)/$$out$$ext $(PKG) || exit 1; \ + ( cd $(DIST) && \ + if [ "$$os" = "windows" ]; then \ + zip -q $$out.zip $$out$$ext && rm -f $$out$$ext; \ + else \ + tar -czf $$out.tar.gz $$out && rm -f $$out; \ + fi ); \ + done + @( cd $(DIST) && sha256sum $(BIN)$(SUFFIX)-$(VERSION)-* > checksums-$(BIN)$(SUFFIX)-$(VERSION).txt 2>/dev/null \ + || shasum -a 256 $(BIN)$(SUFFIX)-$(VERSION)-* > checksums-$(BIN)$(SUFFIX)-$(VERSION).txt ) + @echo "release artifacts in $(DIST)/" + +## clean: remove build artifacts +clean: ## remove build artifacts + rm -rf $(DIST) coverage.out coverage.html vault-tui vault-tui-cloud + +## version: print the version make would embed +version: ## print the version make would embed + @echo "$(VERSION) (commit $(COMMIT), built $(DATE))" + +.DEFAULT_GOAL := help diff --git a/README.md b/README.md new file mode 100644 index 0000000..d5611bf --- /dev/null +++ b/README.md @@ -0,0 +1,231 @@ +# vault-tui + +A terminal UI and CLI for HashiCorp Vault, built on [Bubble Tea v2](https://github.com/charmbracelet/bubbletea) +and [urfave/cli v3](https://github.com/urfave/cli). Browse mounts and KV secrets, view version +history, and log in with (almost) any Vault auth method — with `VAULT_TOKEN`/an existing +`vault login` session and browser-based OIDC as the two flows it's built around. + +## Install / build + +```sh +make build # dist/vault-tui — default build, no cloud auth +make build-cloud # dist/vault-tui-cloud — with AWS/Azure/GCP auth +make install # go install into $PREFIX/bin ($HOME/.local/bin by default) +make help # every target, with a one-line description of each +``` + +Cloud auth methods (AWS, Azure, GCP) are excluded from the default build — their SDKs pull in +a lot of dependencies most deployments never need. `make build-cloud`/`make install-cloud` +add them (equivalent to `-tags cloud`); `make release`/`make release-cloud` cross-compile both +for linux/darwin/windows × amd64/arm64 into `dist/`, with checksums. `make check` runs +formatting, `go vet`, lint (via `golangci-lint` if installed, else `go vet`), and tests — the +same gate you'd wire into CI. Without `make`, the plain Go commands still work: + +```sh +go build -o vault-tui ./cmd/vault-tui +go build -tags cloud -o vault-tui ./cmd/vault-tui +``` + +## Quick start + +```sh +./vault-tui # launches the TUI (same as `./vault-tui ui`) +``` + +On first run, if no config file exists and you're at an interactive terminal, vault-tui walks +you through creating one (address, namespace, TLS, auth method) and writes it to +`~/.config/vault-tui/config.yaml`. Nothing about your environment is hard-coded into the +tool — you can also skip the wizard entirely and drive everything from flags/env vars, or run +`vault-tui config init` at any time to (re-)run it explicitly. + +If you already have a valid Vault CLI session (`~/.vault-token`, or whatever `token_helper` +your `~/.vault` config points at), vault-tui picks it up automatically — no login needed. + +## Auth methods + +| Method | Notes | +|---|---| +| `token` | Validates an existing token (`VAULT_TOKEN`, `--token`, or your saved `~/.vault-token`) | +| `oidc` | Opens a browser, runs a local callback listener (default `http://localhost:8250/oidc/callback`, same as the Vault CLI) | +| `userpass`, `ldap`, `okta`, `radius` | Username/password-shaped logins; Okta supports TOTP and best-effort Okta Verify push polling | +| `approle` | `role_id` / `secret_id` | +| `github` | Personal access token | +| `jwt` | Role + JWT | +| `kubernetes` | Reads the projected service-account token from disk | +| `cert` | TLS client-certificate auth (configure `tls.client_cert`/`tls.client_key` on the profile) | +| `aws`, `azure`, `gcp` | Behind `-tags cloud` (see above) | + +Every method works identically from the TUI, from `vault-tui login -method= [key=value ...]`, +and from a profile's `auth:` config block — the same `Method`/`Field` declarations drive the +TUI form, the CLI prompts, and env-var lookup (`VAULT_TUI_AUTH_` plus each field's own +fallback, e.g. `VAULT_PASSWORD`). + +## CLI commands + +``` +vault-tui launch the TUI (default) +vault-tui login [-method=...] authenticate and save the resulting token +vault-tui logout [--revoke] forget (optionally revoke) the saved token +vault-tui status show the resolved profile, address, and token info +vault-tui list list secrets under a path +vault-tui read read a secret +vault-tui write k=v ... create/update a secret (needs --write) +vault-tui delete delete/destroy a secret (needs --write) +vault-tui config init|show|path manage the config file +``` + +Everything defaults to **read-only**. Pass `--write` (or set `read_only: false` in config) to +allow `write`/`delete`, and the same flag applies inside the TUI (write-capable keys are +disabled and hidden from help when read-only). + +## Configuration + +Discovery order: `--config` flag → `VAULT_TUI_CONFIG` env → `~/.config/vault-tui/config.yaml` +(XDG-aware). Every connection setting resolves through the same precedence: + +``` +CLI flag > env var (VAULT_ADDR, VAULT_NAMESPACE, VAULT_CACERT, ...) > profile > defaults block > builtin +``` + +`vault-tui status` / `config show` display the effective value; multi-profile setups that +want to ignore a stray `VAULT_ADDR` in the shell can set `ignore_env: true` on a profile, or +pass `--no-env` for one run. + +
+Example config.yaml + +```yaml +version: 1 +current_profile: prod + +ui: + appearance: auto # auto | dark | light + confirm_destructive: true + mask_values: true + require_cas: true + +defaults: + client: + timeout: 60s + max_retries: 2 + token: + storage: vault-cli # vault-cli | profile | none + auto_renew: true + revoke_on_logout: false + +profiles: + prod: + address: https://vault.example.com:8200 + namespace: admin/platform + auth: + method: oidc + mount: oidc + params: + role: engineering + oidc: + port: 8250 + callback_host: localhost + + staging: + address: https://vault-staging.example.com:8200 + ignore_env: true # a stray VAULT_ADDR must not hijack this profile + auth: + method: oidc + oidc: + port: 8251 # so prod and staging logins can run concurrently + token: + storage: profile # its own file, doesn't touch ~/.vault-token + + ci: + address: https://vault.example.com:8200 + auth: + method: approle + params: + role_id: 8c3a9b0e-4f21-4a0d-9d0c-1b2c3d4e5f60 + # secret_id intentionally absent — supply via VAULT_TUI_AUTH_SECRET_ID + token: + storage: none # never persist a CI token to disk + +keys: + quit: ["q", "ctrl+q"] # override any default binding + down: ["j", "down", "ctrl+n"] + +theme: + border_style: rounded # rounded | normal | thick | double | ascii | hidden + colors: + accent: {light: "#5A32B0", dark: "#B39DFF"} + error: "#FF6B6B" # single value = same in light and dark +``` +
+ +### Token storage + +- `vault-cli` (default): shares `~/.vault-token` — or whatever external `token_helper` your + `~/.vault` file configures — with the real Vault CLI. Logging in via either tool updates + the token the other one sees. +- `profile`: its own file under `$XDG_STATE_HOME/vault-tui/tokens/.token`, for + running multiple profiles/namespaces side by side without clobbering each other. +- `none`: never persisted (CI, scripted one-shots). + +### Keybindings & theme + +`keys:` rebinds any action by name (unknown names or colliding bindings fail at startup, not +at the moment you press the key); `theme:` recolors everything via named colors that accept +either a single hex value or a `{light, dark}` pair. `theme.mask_char` picks the character +secret values are masked with (default `•`). `--no-color`/`NO_COLOR` fall back to a monochrome +palette that carries hierarchy through weight instead of color. See the example above and +`internal/ui/keys/keymap.go` / `internal/ui/theme/theme.go` for the full action/color lists. + +## Default TUI keybindings + +`j`/`k` or arrows to move, `enter`/`l` to open, `esc`/`h` to go back, `/` to filter a list, +`?` for the full keybinding overlay, `q`/`ctrl+c` to quit. `p` switches profiles (when more +than one is configured) and `ctrl+l` logs out — both available from anywhere. + +On a secret: `s` shows/hides the selected value, `S` all of them, `y` copies the selected +value (auto-cleared from the clipboard after `ui.clipboard_clear_after`, if set), `e` edits, +`d`/`D` soft-delete/destroy, `V` opens version history. On version history: `enter` views +that version, `u` undeletes it, `R` rolls back to it (creates a new version with its data). +Destructive actions render as a centered confirmation overlay, and irreversible ones +(destroy, non-KV-v2 delete) require typing the path back to confirm. + +The TUI adapts its layout to the terminal size: a side panel with mount/secret details +appears once the terminal is wide enough, and chrome (badge, pills, help text) simplifies +progressively as it narrows; below roughly 44×12 cells it shows a plain "too small" notice +instead of a garbled layout. + +## Architecture + +``` +internal/config YAML schema, precedence engine (flag > env > profile > defaults > builtin) +internal/vault api.Client wrapper, KV v1/v2 path mapping, mount discovery +internal/token token resolution/storage (vault-cli compatible), lookup, auto-renew +internal/auth Method/Field abstraction + every login method (incl. OIDC in auth/oidc) +internal/cli urfave/cli/v3 commands — the headless surface +internal/ui Bubble Tea v2 TUI, built entirely on internal/vault + internal/auth +``` + +`internal/vault` and `internal/auth` never import a TUI toolkit; `internal/ui` never imports +`hashicorp/vault/api` directly. The CLI and TUI are two front ends over the identical +Service/Method abstractions, which is what makes `vault-tui login -method=x` and picking +the same method in the TUI behave identically. + +### Mount discovery + +`sys/mounts` requires broad permissions and commonly 403s for ordinary tokens. vault-tui +tries it first (richer detail when it works) and falls back to `sys/internal/ui/mounts` — +the same endpoint the Vault web UI uses, scoped to what your token can actually see. + +## Known limitations + +- Okta Verify push-number display is best-effort: the `verify/` polling endpoint isn't + part of Vault's documented public API, so a poll failure is silently ignored and the login + falls back to waiting on the original request. + +## A note on how this was built + +This tool was written almost entirely by Claude (Anthropic) as a side project scratch-my-own-itch +— avoiding the Vault web UI from inside WSL. I've reviewed and tested it for my own use, but I +have no interest in maintaining it as a hand-written codebase and don't plan to write code in +this repo myself. I'll happily look at and merge pull requests, but there's no support and no +warranty of any kind — use at your own risk. diff --git a/cmd/vault-tui/main.go b/cmd/vault-tui/main.go new file mode 100644 index 0000000..c24fbdd --- /dev/null +++ b/cmd/vault-tui/main.go @@ -0,0 +1,22 @@ +// Command vault-tui is a terminal UI and CLI for HashiCorp Vault. +package main + +import ( + "context" + "fmt" + "os" + + "git.morlana.online/f.weber/vault-tui/internal/cli" + _ "git.morlana.online/f.weber/vault-tui/internal/ui" // registers the "ui" command's runner +) + +// version is overridden at build time via -ldflags "-X main.version=...". +var version = "dev" + +func main() { + root := cli.Root(version) + if err := root.Run(context.Background(), os.Args); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..de991ce --- /dev/null +++ b/go.mod @@ -0,0 +1,88 @@ +module git.morlana.online/f.weber/vault-tui + +go 1.26.5 + +require ( + charm.land/bubbles/v2 v2.1.1 + charm.land/bubbletea/v2 v2.0.7 + charm.land/lipgloss/v2 v2.0.6 + github.com/charmbracelet/x/term v0.2.2 + github.com/hashicorp/vault/api v1.23.0 + github.com/hashicorp/vault/api/auth/aws v0.12.0 + github.com/hashicorp/vault/api/auth/azure v0.11.0 + github.com/hashicorp/vault/api/auth/gcp v0.12.0 + github.com/natefinch/atomic v1.0.1 + github.com/urfave/cli/v3 v3.10.1 + golang.org/x/term v0.45.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + cloud.google.com/go/auth v0.16.2 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + cloud.google.com/go/iam v1.5.2 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aws/aws-sdk-go v1.55.7 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect + github.com/charmbracelet/x/ansi v0.11.8 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect + github.com/googleapis/gax-go/v2 v2.14.2 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-hclog v1.6.3 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/hashicorp/go-rootcerts v1.0.2 // indirect + github.com/hashicorp/go-secure-stdlib/awsutil v0.3.0 // indirect + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect + github.com/hashicorp/go-sockaddr v1.0.7 // indirect + github.com/hashicorp/go-uuid v1.0.2 // indirect + github.com/hashicorp/hcl v1.0.1-vault-7 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.1 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.24 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect + github.com/sahilm/fuzzy v0.1.3 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.32.0 // indirect + golang.org/x/time v0.12.0 // indirect + google.golang.org/api v0.242.0 // indirect + google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/grpc v1.79.3 // indirect + google.golang.org/protobuf v1.36.10 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..08ce47c --- /dev/null +++ b/go.sum @@ -0,0 +1,248 @@ +charm.land/bubbles/v2 v2.1.1 h1:7r55WzBxpo/R3z98hGmY7KKPd3ET6vsf0Fb9sDHOV60= +charm.land/bubbles/v2 v2.1.1/go.mod h1:GE6M31gaWZVXzGw73OeuTTgy4lX+OtkH0E5ymnNsHxo= +charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0= +charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs= +charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= +charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q= +cloud.google.com/go/auth v0.16.2 h1:QvBAGFPLrDeoiNjyfVunhQ10HKNYuOwZ5noee0M5df4= +cloud.google.com/go/auth v0.16.2/go.mod h1:sRBas2Y1fB1vZTdurouM0AzuYQBMZinrUYL8EufhtEA= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8= +cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aws/aws-sdk-go v1.34.0/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= +github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE= +github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +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.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA= +github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro= +github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ= +github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= +github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= +github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-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-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +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/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +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/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= +github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= +github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0= +github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w= +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.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.5.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +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-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-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +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/awsutil v0.3.0 h1:I8bynUKMh9I7JdwtW9voJ0xmHvBpxQtLjrMFDYmhOxY= +github.com/hashicorp/go-secure-stdlib/awsutil v0.3.0/go.mod h1:oKHSQs4ivIfZ3fbXGQOop1XuDfdSb8RIsWTGaAanSfg= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= +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.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= +github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= +github.com/hashicorp/go-uuid v1.0.2 h1:cfejS+Tpcp13yd5nYHWDI6qVCny6wyX2Mt5SGur2IGE= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= +github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= +github.com/hashicorp/vault/api v1.23.0 h1:gXgluBsSECfRWTSW9niY2jwg2e9mMJc4WoHNv4g3h6A= +github.com/hashicorp/vault/api v1.23.0/go.mod h1:zransKiB9ftp+kgY8ydjnvCU7Wk8i9L0DYWpXeMj9ko= +github.com/hashicorp/vault/api/auth/aws v0.12.0 h1:onkMrv49rQCF5Zx1/BdIEvwyhh9R2mXkgfZFWdW+kfM= +github.com/hashicorp/vault/api/auth/aws v0.12.0/go.mod h1:Cuyla0RLfTnPkaJCaHGfNGsNIY1GqB2G79T7XI/9N+I= +github.com/hashicorp/vault/api/auth/azure v0.11.0 h1:GKzT6Ndk8/BpKSi0yrsqWZkuHjSj8L6O5eYUrF1PvVA= +github.com/hashicorp/vault/api/auth/azure v0.11.0/go.mod h1:QwvMxclqWoirwzT/prHGfjOqcx6xZMGiTOp3ejz8KWg= +github.com/hashicorp/vault/api/auth/gcp v0.12.0 h1:l3M4CmQ3mejhxXMzP6IvGOE7nFMXPS7GjYsc1Rc6v8Y= +github.com/hashicorp/vault/api/auth/gcp v0.12.0/go.mod h1:PMD2H8Pcj+bBgKNA7JyW5MFX0oZ6G3DNWzktAEarHos= +github.com/jmespath/go-jmespath v0.3.0/go.mod h1:9QtRXoHjLGCJ5IBSaohpXITPlowMeeYCZ7fLUTSywik= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= +github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +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.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= +github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +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/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A= +github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +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/sahilm/fuzzy v0.1.3 h1:juByESSS32nVD81vr6tHmKmA/8zde7gE+x5CLxrzXPU= +github.com/sahilm/fuzzy v0.1.3/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +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/urfave/cli/v3 v3.10.1 h1:7Kx9H50hrHbRbyxgO1KP6/BcbiGRz0uYh5YyQ30JEEY= +github.com/urfave/cli/v3 v3.10.1/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +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/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= +go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +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-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/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.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/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/api v0.242.0 h1:7Lnb1nfnpvbkCiZek6IXKdJ0MFuAZNAJKQfA1ws62xg= +google.golang.org/api v0.242.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50= +google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 h1:1tXaIXCracvtsRxSBsYDiSBN0cuJvM7QYW+MrpIRY78= +google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:49MsLSx0oWMOZqcpB3uL8ZOkAh1+TndpJ8ONoCBWiZk= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/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/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/auth/m_approle.go b/internal/auth/m_approle.go new file mode 100644 index 0000000..75bc71a --- /dev/null +++ b/internal/auth/m_approle.go @@ -0,0 +1,32 @@ +package auth + +import ( + "context" + + "github.com/hashicorp/vault/api" +) + +func init() { register(approleMethod{}) } + +type approleMethod struct{} + +func (approleMethod) Name() string { return "approle" } +func (approleMethod) DisplayName() string { return "AppRole" } +func (approleMethod) DefaultMount() string { return "approle" } + +func (approleMethod) Fields() []Field { + return []Field{ + {Name: "role_id", Label: "Role ID", Kind: FieldText, Required: true, + EnvFallback: []string{"VAULT_ROLE_ID"}}, + {Name: "secret_id", Label: "Secret ID", Kind: FieldSecret, Required: true, + EnvFallback: []string{"VAULT_SECRET_ID"}}, + } +} + +func (approleMethod) Login(ctx context.Context, c *api.Client, req Request) (*api.Secret, error) { + mount := mountOf(req, "approle") + return loginWrite(ctx, c, mount, "login", map[string]interface{}{ + "role_id": req.Creds.Get("role_id"), + "secret_id": req.Creds.Get("secret_id"), + }) +} diff --git a/internal/auth/m_cert.go b/internal/auth/m_cert.go new file mode 100644 index 0000000..12ff090 --- /dev/null +++ b/internal/auth/m_cert.go @@ -0,0 +1,42 @@ +package auth + +import ( + "context" + + "github.com/hashicorp/vault/api" +) + +func init() { register(certMethod{}) } + +// certMethod is TLS client-cert auth. The certificate itself is supplied at +// the transport layer via profile.tls.client_cert/client_key (see +// config.Settings and internal/vault.NewClient) — this method's Login is +// just the POST that tells Vault which cert role to match against. +// +// api/auth/cert has no tagged release (only a pseudo-version on the module +// proxy), so this is implemented as a two-line raw request rather than +// pulling in an unreleased dependency. +type certMethod struct{} + +func (certMethod) Name() string { return "cert" } +func (certMethod) DisplayName() string { return "TLS Certificate" } +func (certMethod) DefaultMount() string { return "cert" } + +func (certMethod) Description() string { + return "Client-certificate auth. Configure tls.client_cert / tls.client_key on the profile first." +} + +func (certMethod) Fields() []Field { + return []Field{ + {Name: "name", Label: "Cert role name (optional)", Kind: FieldText}, + } +} + +func (certMethod) Login(ctx context.Context, c *api.Client, req Request) (*api.Secret, error) { + mount := mountOf(req, "cert") + data := map[string]interface{}{} + if name := req.Creds.Get("name"); name != "" { + data["name"] = name + } + return loginWrite(ctx, c, mount, "login", data) +} diff --git a/internal/auth/m_cloud.go b/internal/auth/m_cloud.go new file mode 100644 index 0000000..9992f43 --- /dev/null +++ b/internal/auth/m_cloud.go @@ -0,0 +1,110 @@ +//go:build cloud + +package auth + +import ( + "context" + + "github.com/hashicorp/vault/api" + awsauth "github.com/hashicorp/vault/api/auth/aws" + azureauth "github.com/hashicorp/vault/api/auth/azure" + gcpauth "github.com/hashicorp/vault/api/auth/gcp" +) + +// Cloud auth methods (AWS/Azure/GCP) are gated behind the `cloud` build +// tag: their SDKs transitively pull in tens of MB of dependencies +// (aws-sdk-go, google.golang.org/api, the Azure SDK) that a typical +// OIDC/userpass/LDAP-only deployment never needs. Build with `-tags cloud` +// to include them; see m_cloud_stub.go for the default (excluded) build, +// which lists these names in the picker greyed out with a reason instead +// of letting them silently vanish. +func init() { + register(awsMethod{}, azureMethod{}, gcpMethod{}) +} + +type awsMethod struct{} + +func (awsMethod) Name() string { return "aws" } +func (awsMethod) DisplayName() string { return "AWS" } +func (awsMethod) DefaultMount() string { return "aws" } + +func (awsMethod) Fields() []Field { + return []Field{ + {Name: "role", Label: "Role", Kind: FieldText, Required: true, EnvFallback: []string{"VAULT_AUTH_AWS_ROLE"}}, + {Name: "type", Label: "Auth type", Kind: FieldSelect, Options: []string{"iam", "ec2"}, Default: "iam"}, + {Name: "region", Label: "AWS region (optional)", Kind: FieldText}, + } +} + +func (awsMethod) Login(ctx context.Context, c *api.Client, req Request) (*api.Secret, error) { + opts := []awsauth.LoginOption{awsauth.WithMountPath(mountOf(req, "aws"))} + if r := req.Creds.Get("role"); r != "" { + opts = append(opts, awsauth.WithRole(r)) + } + if r := req.Creds.Get("region"); r != "" { + opts = append(opts, awsauth.WithRegion(r)) + } + if req.Creds.Get("type") == "ec2" { + opts = append(opts, awsauth.WithEC2Auth()) + } else { + opts = append(opts, awsauth.WithIAMAuth()) + } + a, err := awsauth.NewAWSAuth(opts...) + if err != nil { + return nil, err + } + return a.Login(ctx, c) +} + +type azureMethod struct{} + +func (azureMethod) Name() string { return "azure" } +func (azureMethod) DisplayName() string { return "Azure" } +func (azureMethod) DefaultMount() string { return "azure" } + +func (azureMethod) Fields() []Field { + return []Field{ + {Name: "role", Label: "Role", Kind: FieldText, Required: true, EnvFallback: []string{"VAULT_AUTH_AZURE_ROLE"}}, + {Name: "resource", Label: "Resource URL (optional)", Kind: FieldText}, + } +} + +func (azureMethod) Login(ctx context.Context, c *api.Client, req Request) (*api.Secret, error) { + opts := []azureauth.LoginOption{azureauth.WithMountPath(mountOf(req, "azure"))} + if r := req.Creds.Get("resource"); r != "" { + opts = append(opts, azureauth.WithResource(r)) + } + a, err := azureauth.NewAzureAuth(req.Creds.Get("role"), opts...) + if err != nil { + return nil, err + } + return a.Login(ctx, c) +} + +type gcpMethod struct{} + +func (gcpMethod) Name() string { return "gcp" } +func (gcpMethod) DisplayName() string { return "GCP" } +func (gcpMethod) DefaultMount() string { return "gcp" } + +func (gcpMethod) Fields() []Field { + return []Field{ + {Name: "role", Label: "Role", Kind: FieldText, Required: true, EnvFallback: []string{"VAULT_AUTH_GCP_ROLE"}}, + {Name: "type", Label: "Auth type", Kind: FieldSelect, Options: []string{"iam", "gce"}, Default: "iam"}, + {Name: "service_account", Label: "Service account email (iam only)", Kind: FieldText}, + } +} + +func (gcpMethod) Login(ctx context.Context, c *api.Client, req Request) (*api.Secret, error) { + opts := []gcpauth.LoginOption{gcpauth.WithMountPath(mountOf(req, "gcp"))} + if req.Creds.Get("type") == "gce" { + opts = append(opts, gcpauth.WithGCEAuth()) + } else { + opts = append(opts, gcpauth.WithIAMAuth(req.Creds.Get("service_account"))) + } + a, err := gcpauth.NewGCPAuth(req.Creds.Get("role"), opts...) + if err != nil { + return nil, err + } + return a.Login(ctx, c) +} diff --git a/internal/auth/m_cloud_stub.go b/internal/auth/m_cloud_stub.go new file mode 100644 index 0000000..39b36db --- /dev/null +++ b/internal/auth/m_cloud_stub.go @@ -0,0 +1,11 @@ +//go:build !cloud + +package auth + +// Default build: cloud auth methods are compiled out (see m_cloud.go). +// Listing them as "unavailable" rather than omitting them means a method +// picker can show "AWS (built without cloud auth support — build with +// -tags cloud)" instead of the option silently not existing. +func init() { + registerUnavailable("built without cloud auth support (build with -tags cloud)", "aws", "azure", "gcp") +} diff --git a/internal/auth/m_github.go b/internal/auth/m_github.go new file mode 100644 index 0000000..f020377 --- /dev/null +++ b/internal/auth/m_github.go @@ -0,0 +1,29 @@ +package auth + +import ( + "context" + + "github.com/hashicorp/vault/api" +) + +func init() { register(githubMethod{}) } + +type githubMethod struct{} + +func (githubMethod) Name() string { return "github" } +func (githubMethod) DisplayName() string { return "GitHub" } +func (githubMethod) DefaultMount() string { return "github" } + +func (githubMethod) Fields() []Field { + return []Field{ + {Name: "token", Label: "GitHub personal access token", Kind: FieldSecret, Required: true, + EnvFallback: []string{"VAULT_AUTH_GITHUB_TOKEN"}}, + } +} + +func (githubMethod) Login(ctx context.Context, c *api.Client, req Request) (*api.Secret, error) { + mount := mountOf(req, "github") + return loginWrite(ctx, c, mount, "login", map[string]interface{}{ + "token": req.Creds.Get("token"), + }) +} diff --git a/internal/auth/m_jwt.go b/internal/auth/m_jwt.go new file mode 100644 index 0000000..3d9f459 --- /dev/null +++ b/internal/auth/m_jwt.go @@ -0,0 +1,32 @@ +package auth + +import ( + "context" + + "github.com/hashicorp/vault/api" +) + +func init() { register(jwtMethod{}) } + +type jwtMethod struct{} + +func (jwtMethod) Name() string { return "jwt" } +func (jwtMethod) DisplayName() string { return "JWT" } +func (jwtMethod) DefaultMount() string { return "jwt" } + +func (jwtMethod) Fields() []Field { + return []Field{ + {Name: "role", Label: "Role", Kind: FieldText}, + {Name: "jwt", Label: "JWT", Kind: FieldSecret, Required: true, + EnvFallback: []string{"VAULT_AUTH_JWT"}}, + } +} + +func (jwtMethod) Login(ctx context.Context, c *api.Client, req Request) (*api.Secret, error) { + mount := mountOf(req, "jwt") + data := map[string]interface{}{"jwt": req.Creds.Get("jwt")} + if role := req.Creds.Get("role"); role != "" { + data["role"] = role + } + return loginWrite(ctx, c, mount, "login", data) +} diff --git a/internal/auth/m_kubernetes.go b/internal/auth/m_kubernetes.go new file mode 100644 index 0000000..1e97ec3 --- /dev/null +++ b/internal/auth/m_kubernetes.go @@ -0,0 +1,46 @@ +package auth + +import ( + "context" + "fmt" + "os" + + "github.com/hashicorp/vault/api" +) + +func init() { register(kubernetesMethod{}) } + +// defaultServiceAccountTokenPath is where a projected service account token +// is normally mounted inside a pod. +const defaultServiceAccountTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" + +type kubernetesMethod struct{} + +func (kubernetesMethod) Name() string { return "kubernetes" } +func (kubernetesMethod) DisplayName() string { return "Kubernetes" } +func (kubernetesMethod) DefaultMount() string { return "kubernetes" } + +func (kubernetesMethod) Fields() []Field { + return []Field{ + {Name: "role", Label: "Role", Kind: FieldText, Required: true, + EnvFallback: []string{"VAULT_AUTH_KUBERNETES_ROLE"}}, + {Name: "jwt_path", Label: "Service account token path", Kind: FieldPath, + Default: defaultServiceAccountTokenPath}, + } +} + +func (kubernetesMethod) Login(ctx context.Context, c *api.Client, req Request) (*api.Secret, error) { + mount := mountOf(req, "kubernetes") + jwtPath := req.Creds.Get("jwt_path") + if jwtPath == "" { + jwtPath = defaultServiceAccountTokenPath + } + jwt, err := os.ReadFile(jwtPath) + if err != nil { + return nil, fmt.Errorf("reading service account token from %s: %w", jwtPath, err) + } + return loginWrite(ctx, c, mount, "login", map[string]interface{}{ + "role": req.Creds.Get("role"), + "jwt": string(jwt), + }) +} diff --git a/internal/auth/m_ldap.go b/internal/auth/m_ldap.go new file mode 100644 index 0000000..2b851ff --- /dev/null +++ b/internal/auth/m_ldap.go @@ -0,0 +1,98 @@ +package auth + +import ( + "context" + "fmt" + "net/url" + "time" + + "github.com/hashicorp/vault/api" +) + +func init() { register(ldapMethod{}) } + +type ldapMethod struct{} + +func (ldapMethod) Name() string { return "ldap" } +func (ldapMethod) DisplayName() string { return "LDAP" } +func (ldapMethod) DefaultMount() string { return "ldap" } + +func (ldapMethod) Fields() []Field { + return []Field{ + {Name: "username", Label: "Username", Kind: FieldText, Required: true, + EnvFallback: []string{"VAULT_USERNAME", "LOGNAME", "USER"}}, + {Name: "password", Label: "Password", Kind: FieldSecret, Required: true, + EnvFallback: []string{"VAULT_LDAP_PASSWORD", "VAULT_PASSWORD"}}, + } +} + +func (ldapMethod) Login(ctx context.Context, c *api.Client, req Request) (*api.Secret, error) { + mount := mountOf(req, "ldap") + return loginWrite(ctx, c, mount, "login/"+url.PathEscape(req.Creds.Get("username")), + map[string]interface{}{"password": req.Creds.Get("password")}) +} + +func init() { register(oktaMethod{}) } + +// oktaMethod mirrors ldapMethod's request shape but adds an optional +// best-effort poll of auth//verify/ for Okta Verify +// number-matching, reported via Request.Events so the TUI can show "tap 42 +// in Okta Verify". This endpoint is not documented in Vault's public API +// reference; treat any poll failure as non-fatal and fall back to waiting +// for the original login response. +type oktaMethod struct{} + +func (oktaMethod) Name() string { return "okta" } +func (oktaMethod) DisplayName() string { return "Okta" } +func (oktaMethod) DefaultMount() string { return "okta" } + +func (oktaMethod) Description() string { + return "Okta username/password, with optional TOTP and Okta Verify push." +} + +func (oktaMethod) Fields() []Field { + return []Field{ + {Name: "username", Label: "Username", Kind: FieldText, Required: true, + EnvFallback: []string{"VAULT_USERNAME", "LOGNAME", "USER"}}, + {Name: "password", Label: "Password", Kind: FieldSecret, Required: true, + EnvFallback: []string{"VAULT_PASSWORD"}}, + {Name: "totp", Label: "TOTP code (optional)", Kind: FieldText}, + } +} + +func (oktaMethod) Login(ctx context.Context, c *api.Client, req Request) (*api.Secret, error) { + mount := mountOf(req, "okta") + data := map[string]interface{}{"password": req.Creds.Get("password")} + if totp := req.Creds.Get("totp"); totp != "" { + data["totp"] = totp + } + nonce := randomNonce(12) + data["nonce"] = nonce + + if req.Events != nil { + go pollOktaVerify(ctx, c, mount, nonce, req) + } + req.Emit(Event{Kind: EventStatus, Message: "contacting Okta…"}) + return loginWrite(ctx, c, mount, "login/"+url.PathEscape(req.Creds.Get("username")), data) +} + +func pollOktaVerify(ctx context.Context, c *api.Client, mount, nonce string, req Request) { + t := time.NewTicker(1 * time.Second) + defer t.Stop() + p := fmt.Sprintf("auth/%s/verify/%s", mount, nonce) + for { + select { + case <-ctx.Done(): + return + case <-t.C: + sec, err := c.Logical().ReadWithContext(ctx, p) + if err != nil || sec == nil || sec.Data == nil { + continue // best-effort; the primary login request is the source of truth + } + if answer, ok := sec.Data["correct_answer"].(string); ok && answer != "" { + req.Emit(Event{Kind: EventStatus, Message: fmt.Sprintf("in Okta Verify, tap the number %q", answer)}) + return + } + } + } +} diff --git a/internal/auth/m_token.go b/internal/auth/m_token.go new file mode 100644 index 0000000..5c8d7d5 --- /dev/null +++ b/internal/auth/m_token.go @@ -0,0 +1,64 @@ +package auth + +import ( + "context" + "fmt" + + "github.com/hashicorp/vault/api" +) + +func init() { register(tokenMethod{}) } + +// tokenMethod is the trivial "I already have a token" method: it validates +// the given token via lookup-self and returns a synthesized auth response so +// the normal Result/token-storage pipeline works unchanged. This is what +// backs `vault-tui login -method=token` and the VAULT_TOKEN/--token fast +// path that internal/token.Resolve prefers over any interactive login. +type tokenMethod struct{} + +func (tokenMethod) Name() string { return "token" } +func (tokenMethod) DisplayName() string { return "Token" } +func (tokenMethod) DefaultMount() string { return "token" } + +func (tokenMethod) Description() string { + return "Use an existing Vault token (from VAULT_TOKEN, --token, or a saved token file)." +} + +func (tokenMethod) Fields() []Field { + return []Field{ + {Name: "token", Label: "Token", Kind: FieldSecret, Required: true, + EnvFallback: []string{"VAULT_TOKEN"}}, + } +} + +func (tokenMethod) Login(ctx context.Context, c *api.Client, req Request) (*api.Secret, error) { + tok := req.Creds.Get("token") + if tok == "" { + return nil, fmt.Errorf("no token provided") + } + // lookup-self must run with the candidate token; c is otherwise + // unauthenticated at this point (see internal/vault.NewClient's + // ClearToken discipline), so this cannot affect any other caller. + c.SetToken(tok) + defer c.ClearToken() + + sec, err := c.Logical().ReadWithContext(ctx, "auth/token/lookup-self") + if err != nil { + return nil, fmt.Errorf("validating token: %w", err) + } + if sec == nil || sec.Data == nil { + return nil, fmt.Errorf("token lookup returned no data") + } + + renewable, _ := sec.TokenIsRenewable() + ttl, _ := sec.TokenTTL() + policies, _ := sec.TokenPolicies() + return &api.Secret{ + Auth: &api.SecretAuth{ + ClientToken: tok, + Renewable: renewable, + LeaseDuration: int(ttl.Seconds()), + Policies: policies, + }, + }, nil +} diff --git a/internal/auth/m_userpass.go b/internal/auth/m_userpass.go new file mode 100644 index 0000000..c5741b8 --- /dev/null +++ b/internal/auth/m_userpass.go @@ -0,0 +1,39 @@ +package auth + +import ( + "context" + "net/url" + + "github.com/hashicorp/vault/api" +) + +func init() { + register( + userpassMethod{name: "userpass", display: "Username & Password", mount: "userpass"}, + userpassMethod{name: "radius", display: "RADIUS", mount: "radius"}, + ) +} + +// userpassMethod covers both userpass and radius: upstream's own CLI +// handler registers radius as credUserpass with DefaultMount "radius", i.e. +// they are the same request shape. +type userpassMethod struct{ name, display, mount string } + +func (m userpassMethod) Name() string { return m.name } +func (m userpassMethod) DisplayName() string { return m.display } +func (m userpassMethod) DefaultMount() string { return m.mount } + +func (m userpassMethod) Fields() []Field { + return []Field{ + {Name: "username", Label: "Username", Kind: FieldText, Required: true, + EnvFallback: []string{"VAULT_USERNAME", "LOGNAME", "USER"}}, + {Name: "password", Label: "Password", Kind: FieldSecret, Required: true, + EnvFallback: []string{"VAULT_PASSWORD"}}, + } +} + +func (m userpassMethod) Login(ctx context.Context, c *api.Client, req Request) (*api.Secret, error) { + mount := mountOf(req, m.mount) + return loginWrite(ctx, c, mount, "login/"+url.PathEscape(req.Creds.Get("username")), + map[string]interface{}{"password": req.Creds.Get("password")}) +} diff --git a/internal/auth/method.go b/internal/auth/method.go new file mode 100644 index 0000000..4826740 --- /dev/null +++ b/internal/auth/method.go @@ -0,0 +1,173 @@ +// Package auth defines the auth-method abstraction vault-tui uses for every +// login flow. A Method declares only which fields it needs (Fields); the +// TUI and headless CLI both render that declaration generically — a +// textinput form in the TUI, a TTY prompt or env/flag lookup on the CLI — +// so adding a new method never requires UI code (see prefill.go and +// registry.go). +package auth + +import ( + "context" + "time" + + "github.com/hashicorp/vault/api" +) + +// FieldKind tells a renderer how to present a field. +type FieldKind uint8 + +const ( + FieldText FieldKind = iota + FieldSecret + FieldBool + FieldSelect + FieldPath + FieldInt +) + +// Field declares one credential input a Method needs. +type Field struct { + Name string + Label string + Help string + Kind FieldKind + Required bool + Default string + Options []string + + // EnvFallback lists env vars consulted during Prefill, in order, before + // falling back to config params. Mirrors Vault CLI behaviour (e.g. + // VAULT_AUTH_GITHUB_TOKEN-style env overrides). + EnvFallback []string + + // ConfigKey is the key looked up in profile.auth.params. Empty => Name. + ConfigKey string + + // Validate runs before Login and must not have side effects. + Validate func(value string) error +} + +// Credentials is a filled-in form: field name -> value. +type Credentials map[string]string + +func (c Credentials) Get(name string) string { return c[name] } +func (c Credentials) Has(name string) bool { + v, ok := c[name] + return ok && v != "" +} + +// Redacted returns a copy safe for logs: every FieldSecret value becomes +// "***". This is the only sanctioned way to log a Credentials map. +func (c Credentials) Redacted(fields []Field) map[string]string { + secret := map[string]bool{} + for _, f := range fields { + if f.Kind == FieldSecret { + secret[f.Name] = true + } + } + out := make(map[string]string, len(c)) + for k, v := range c { + if secret[k] { + out[k] = "***" + } else { + out[k] = v + } + } + return out +} + +// EventKind classifies out-of-band progress from a long-running login (OIDC +// browser wait, Okta push polling). +type EventKind uint8 + +const ( + EventStatus EventKind = iota + EventOpenURL + EventWarning +) + +// Event is one piece of progress emitted on Request.Events. +type Event struct { + Kind EventKind + Message string + URL string +} + +// Request is everything a Method needs to perform one login. +type Request struct { + Mount string + Namespace string + Creds Credentials + // Events, if non-nil, receives progress notifications. Login must send + // non-blockingly (see emit) so a stalled consumer can never deadlock it. + Events chan<- Event + Timeout time.Duration +} + +// Emit sends a progress event to Request.Events, non-blocking. Safe to +// call even when Events is nil (a headless caller that doesn't want +// progress) or when nobody is currently draining the channel. +func (r Request) Emit(e Event) { + if r.Events == nil { + return + } + select { + case r.Events <- e: + default: + } +} + +// Result is the normalised outcome of a login. +type Result struct { + Secret *api.Secret + Token string + Accessor string + Renewable bool + TTL time.Duration + Policies []string + Namespace string +} + +func (r *Result) String() string { + if r == nil { + return "" + } + return "" +} +func (r *Result) GoString() string { return r.String() } + +// NewResult normalises an *api.Secret returned by a successful Login. +func NewResult(sec *api.Secret, namespace string) (*Result, error) { + if sec == nil || sec.Auth == nil { + return nil, errNoAuth + } + ttl, _ := sec.TokenTTL() + policies, _ := sec.TokenPolicies() + accessor, _ := sec.TokenAccessor() + return &Result{ + Secret: sec, + Token: sec.Auth.ClientToken, + Accessor: accessor, + Renewable: sec.Auth.Renewable, + TTL: ttl, + Policies: policies, + Namespace: namespace, + }, nil +} + +// Method is the single abstraction every auth method implements. +// +// Contract: Fields() is pure and cheap. Login must respect ctx cancellation +// and must never read os.Stdin — interactive prompting is the caller's job +// (TUI form or CLI prompt), driven by Fields()/Missing(). Login must not +// mutate client's token; token persistence is internal/token's job. +type Method interface { + Name() string + DisplayName() string + DefaultMount() string + Fields() []Field + Login(ctx context.Context, client *api.Client, req Request) (*api.Secret, error) +} + +// Describable is optional; implemented by methods with extra help text. +type Describable interface{ Description() string } diff --git a/internal/auth/oidc/browser.go b/internal/auth/oidc/browser.go new file mode 100644 index 0000000..9771c3a --- /dev/null +++ b/internal/auth/oidc/browser.go @@ -0,0 +1,107 @@ +package oidc + +import ( + "fmt" + "os" + "os/exec" + "runtime" + "strings" +) + +// openBrowser tries, in order: an explicit command override, $BROWSER, then +// platform-appropriate auto-detection. It exists instead of +// github.com/pkg/browser because that package shells out to xdg-open, which +// is absent on a bare WSL2 install (verified: no xdg-open, no +// xclip/wl-copy, no wslview on this project's own dev machine) — there, +// the working option is handing the URL to the Windows side via +// powershell.exe or cmd.exe. +// +// override, when non-empty, is treated as a command template: "%s" is +// replaced with the URL if present, otherwise the URL is appended as the +// final argument. It is run through the shell so users can supply +// something like `firefox --new-tab %s`. +func openBrowser(override, url string) error { + if override != "" { + return runShell(substituteOrAppend(override, url)) + } + if b := os.Getenv("BROWSER"); b != "" { + return runShell(substituteOrAppend(b, url)) + } + + for _, cand := range candidates(url) { + cmd := exec.Command(cand[0], cand[1:]...) + if err := cmd.Start(); err == nil { + // Don't Wait(): a real browser backgrounds itself, and waiting on + // e.g. `cmd.exe /c start` (which returns immediately anyway) is + // harmless, but waiting on something that stays foregrounded + // would block the login flow. + return nil + } + } + return fmt.Errorf("no working browser launcher found for %s (tried: %s)", runtime.GOOS, candidateNames(url)) +} + +// candidates returns launcher argv lists to try in order, most-specific +// (and most likely to actually work on this host) first. +func candidates(url string) [][]string { + var out [][]string + if isWSL() { + out = append(out, + []string{"wslview", url}, + []string{"powershell.exe", "-NoProfile", "-NonInteractive", "-Command", "Start-Process", quoteForPowerShell(url)}, + []string{"cmd.exe", "/c", "start", "", url}, + ) + } + switch runtime.GOOS { + case "darwin": + out = append(out, []string{"open", url}) + case "windows": + out = append(out, []string{"cmd", "/c", "start", "", url}, + []string{"rundll32", "url.dll,FileProtocolHandler", url}) + default: + out = append(out, []string{"xdg-open", url}, []string{"x-www-browser", url}) + } + return out +} + +func candidateNames(url string) string { + var names []string + for _, c := range candidates(url) { + names = append(names, c[0]) + } + return strings.Join(names, ", ") +} + +// isWSL detects WSL1/2 by checking /proc/version for the "microsoft" +// marker Microsoft's kernel build injects there — the standard, widely +// used detection technique since there's no dedicated syscall for it. +func isWSL() bool { + if runtime.GOOS != "linux" { + return false + } + b, err := os.ReadFile("/proc/version") + if err != nil { + return false + } + v := strings.ToLower(string(b)) + return strings.Contains(v, "microsoft") || strings.Contains(v, "wsl") +} + +func substituteOrAppend(template, url string) string { + if strings.Contains(template, "%s") { + return fmt.Sprintf(template, url) + } + return template + " " + url +} + +func runShell(command string) error { + cmd := exec.Command("sh", "-c", command) + return cmd.Start() +} + +// quoteForPowerShell wraps url in single quotes for use inside a +// -Command argument; OIDC auth URLs are server-generated and may contain +// characters PowerShell would otherwise interpret. +func quoteForPowerShell(url string) string { + return "'" + strings.ReplaceAll(url, "'", "''") + "'" +} diff --git a/internal/auth/oidc/callback.go b/internal/auth/oidc/callback.go new file mode 100644 index 0000000..914311e --- /dev/null +++ b/internal/auth/oidc/callback.go @@ -0,0 +1,102 @@ +package oidc + +import ( + "context" + "fmt" + "net/http" + "net/url" + "path" + "sync" + "time" + + "github.com/hashicorp/vault/api" +) + +// handler builds the http.HandlerFunc that completes the OIDC exchange. +// once guards against a stray second request (retries, prefetchers, a +// double-click) sending on the already-buffered done channel more than +// once — sending twice would be harmless here (done is buffered 1 and +// nobody reads twice) but responding twice to the browser is not, so we +// gate the whole body. +func (f *Flow) handler(c *api.Client, done chan<- result, once *sync.Once) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var res result + handled := false + defer func() { + if !handled { + return + } + if res.err != nil { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write(errorHTML(res.err)) + } else { + _, _ = w.Write(successHTML()) + } + once.Do(func() { done <- res }) + }() + + if r.URL.Path != f.cfg.CallbackPath { + http.NotFound(w, r) + return + } + handled = true + + if err := r.ParseForm(); err != nil { + res.err = fmt.Errorf("parsing callback request: %w", err) + return + } + + if errCode := r.FormValue("error"); errCode != "" { + res.err = ProviderRejectedError{Code: errCode, Description: r.FormValue("error_description")} + return + } + + state := r.FormValue("state") + code := r.FormValue("code") + idToken := r.FormValue("id_token") + if state == "" { + res.err = fmt.Errorf("OIDC callback missing state parameter") + return + } + + // The callback exchange is tied to the Flow's own context (captured + // via closure below is not possible here since Login already holds + // it) — use a background context bounded by a short deadline instead + // of r.Context(), so a browser tab closed mid-exchange does not abort + // an exchange that Vault is still processing. + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + data := url.Values{ + "state": {state}, + "code": {code}, + "id_token": {idToken}, + "client_nonce": {f.nonce}, + } + p := path.Join("auth", f.cfg.Mount, "oidc/callback") + sec, err := c.Logical().ReadWithDataWithContext(ctx, p, data) + if err != nil { + res.err = fmt.Errorf("completing OIDC login: %w", err) + return + } + if sec == nil || sec.Auth == nil || sec.Auth.ClientToken == "" { + res.err = fmt.Errorf("Vault returned no token from the OIDC callback") + return + } + res.secret = sec + } +} + +// ProviderRejectedError wraps an error= / error_description= pair the +// identity provider appended to the redirect (e.g. the user clicked "deny"). +type ProviderRejectedError struct { + Code string + Description string +} + +func (e ProviderRejectedError) Error() string { + if e.Description != "" { + return fmt.Sprintf("identity provider rejected the login: %s (%s)", e.Code, e.Description) + } + return fmt.Sprintf("identity provider rejected the login: %s", e.Code) +} diff --git a/internal/auth/oidc/flow.go b/internal/auth/oidc/flow.go new file mode 100644 index 0000000..0952703 --- /dev/null +++ b/internal/auth/oidc/flow.go @@ -0,0 +1,239 @@ +// Package oidc implements vault-tui's browser-based OIDC login against +// Vault's jwt/oidc auth method. It follows the same auth_url -> +// browser -> local callback -> oidc/callback sequence as the Vault CLI's +// own `-method=oidc` handler, verified against a live Vault+Keycloak +// instance during development, with three deliberate deviations documented +// on Flow.Login and Config.redirectURI. +package oidc + +import ( + "context" + "fmt" + "net" + "time" + + "github.com/hashicorp/vault/api" + + "git.morlana.online/f.weber/vault-tui/internal/auth" +) + +// Config configures one OIDC login flow. Defaults match the Vault CLI's own +// jwt/oidc CLIHandler so a redirect URI registered for `vault login +// -method=oidc` continues to work unchanged. +type Config struct { + Mount string // default "oidc" + Role string // may be empty -> Vault uses the mount's default_role + + ListenAddress string // where WE bind; default "127.0.0.1" (see Login doc) + Port int // default 8250 + + CallbackMethod string // what we TELL Vault/the IdP; default "http" + CallbackHost string // default "localhost" — must match what's registered with the IdP + CallbackPort int // default: == Port + CallbackPath string // default "/oidc/callback" + + SkipBrowser bool + AbortOnBrowserError bool + Timeout time.Duration // default 2m + + BrowserCommand string // explicit override, tried before auto-detection +} + +func (c Config) withDefaults() Config { + if c.Mount == "" { + c.Mount = "oidc" + } + if c.ListenAddress == "" { + c.ListenAddress = "127.0.0.1" + } + if c.Port == 0 { + c.Port = 8250 + } + if c.CallbackMethod == "" { + c.CallbackMethod = "http" + } + if c.CallbackHost == "" { + c.CallbackHost = "localhost" + } + if c.CallbackPort == 0 { + c.CallbackPort = c.Port + } + if c.CallbackPath == "" { + c.CallbackPath = "/oidc/callback" + } + if c.Timeout == 0 { + c.Timeout = 2 * time.Minute + } + return c +} + +// redirectURI is the exact string sent to Vault as redirect_uri and thus +// the URI that must be registered with the identity provider. We build it +// from host/port/path only and never append our own query parameters: +// Vault itself decides whether to add ?namespace=... depending on the +// mount's namespace_in_state setting (default true, which keeps the +// namespace inside the opaque `state` value instead) — see Flow.Login. +func (c Config) redirectURI() string { + return fmt.Sprintf("%s://%s:%d%s", c.CallbackMethod, c.CallbackHost, c.CallbackPort, c.CallbackPath) +} + +func (c Config) listenAddr() string { + return net.JoinHostPort(c.ListenAddress, fmt.Sprint(c.Port)) +} + +// Flow runs one OIDC login. Create a fresh Flow per login attempt — do not +// reuse one across logins. (Vault's own reference implementation registers +// its callback handler on http.DefaultServeMux, which panics on a second +// login in the same process; Flow avoids that by owning a private +// http.ServeMux + http.Server per instance, see listener.go.) +type Flow struct { + cfg Config + nonce string +} + +func New(cfg Config) *Flow { return &Flow{cfg: cfg.withDefaults()} } + +// AsMethod adapts Flow to auth.Method so it can be registered and driven +// generically like every other login method. Role/mount/etc. still come +// from Config (set by the caller from profile.auth.oidc); the only +// Credentials field is the role, which lets a headless `login -method=oidc +// role=eng` override the configured default without touching config.yaml. +type Method struct { + Cfg Config +} + +func (Method) Name() string { return "oidc" } +func (Method) DisplayName() string { return "OIDC (browser)" } +func (Method) DefaultMount() string { return "oidc" } + +func (Method) Fields() []auth.Field { + return []auth.Field{ + {Name: "role", Label: "Role (optional — server default if empty)", Kind: auth.FieldText}, + } +} + +func (m Method) Login(ctx context.Context, c *api.Client, req auth.Request) (*api.Secret, error) { + cfg := m.Cfg + cfg.Mount = req.Mount + if role := req.Creds.Get("role"); role != "" { + cfg.Role = role + } + if req.Timeout > 0 { + cfg.Timeout = req.Timeout + } + f := New(cfg) + return f.Login(ctx, c, req) +} + +// Login runs the full sequence: +// +// 1. generate client_nonce (crypto/rand, never logged/displayed) +// 2. bind the local listener BEFORE calling auth_url, so a port conflict +// surfaces as ErrPortInUse instead of leaving an orphaned state entry +// server-side, and so there is never a window where auth_url has been +// requested but nothing is listening for the redirect yet +// 3. POST auth//oidc/auth_url {role, redirect_uri, client_nonce} +// 4. emit an EventOpenURL with the URL BEFORE attempting to launch a +// browser, so a failed launch degrades to "copy this URL" with no +// separate code path +// 5. serve the callback on a private ServeMux/http.Server +// 6. attempt to open a browser (browser.go) +// 7. wait for: callback done | ctx cancelled | timeout +// +// Deliberate deviations from vault-plugin-auth-jwt's reference CLI handler: +// - a fresh http.ServeMux per Flow (upstream uses http.DefaultServeMux, +// which panics — "multiple registrations" — on a second login in the +// same process; fatal for a long-lived TUI, harmless for a one-shot CLI) +// - the listener binds to 127.0.0.1 explicitly rather than the literal +// string "localhost" (which can resolve to ::1 or elsewhere depending on +// host config), while CallbackHost stays "localhost" for the +// redirect_uri, since that is what is registered with the IdP +// - CSRF/state is verified entirely server-side by Vault (it binds our +// client_nonce to the state it generates); there is nothing for the +// client to compare locally, so none is attempted here +func (f *Flow) Login(ctx context.Context, c *api.Client, req auth.Request) (*api.Secret, error) { + f.nonce = randomNonce(20) + + ln, err := net.Listen("tcp", f.cfg.listenAddr()) + if err != nil { + return nil, PortInUseError{Addr: f.cfg.listenAddr(), Err: err} + } + + authURL, err := f.requestAuthURL(ctx, c) + if err != nil { + ln.Close() + return nil, err + } + req.Emit(auth.Event{Kind: auth.EventOpenURL, URL: authURL, + Message: "open this URL in your browser to finish signing in"}) + + srv, done := f.startServer(ln, c) + defer func() { + shutCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = srv.Shutdown(shutCtx) + ln.Close() + }() + + if !f.cfg.SkipBrowser { + if err := openBrowser(f.cfg.BrowserCommand, authURL); err != nil { + req.Emit(auth.Event{Kind: auth.EventWarning, + Message: fmt.Sprintf("could not open a browser automatically (%v) — use the URL above", err)}) + if f.cfg.AbortOnBrowserError { + return nil, fmt.Errorf("opening browser: %w", err) + } + } + } + + timeout := f.cfg.Timeout + if timeout <= 0 { + timeout = 2 * time.Minute + } + select { + case res := <-done: + if res.err != nil { + return nil, res.err + } + return res.secret, nil + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(timeout): + return nil, fmt.Errorf("timed out after %s waiting for the OIDC callback", timeout) + } +} + +func (f *Flow) requestAuthURL(ctx context.Context, c *api.Client) (string, error) { + p := fmt.Sprintf("auth/%s/oidc/auth_url", f.cfg.Mount) + data := map[string]interface{}{ + "redirect_uri": f.cfg.redirectURI(), + "client_nonce": f.nonce, + } + if f.cfg.Role != "" { + data["role"] = f.cfg.Role + } + sec, err := c.Logical().WriteWithContext(ctx, p, data) + if err != nil { + return "", fmt.Errorf("requesting OIDC auth URL: %w", err) + } + if sec == nil || sec.Data == nil { + return "", fmt.Errorf("%s returned no data", p) + } + authURL, _ := sec.Data["auth_url"].(string) + if authURL == "" { + return "", fmt.Errorf("%s did not return an auth_url", p) + } + return authURL, nil +} + +// PortInUseError is returned when the configured OIDC callback port is +// already bound. Deliberately not falling back to a random port: the +// registered redirect_uri would then no longer match what the IdP expects. +type PortInUseError struct { + Addr string + Err error +} + +func (e PortInUseError) Error() string { + return fmt.Sprintf("OIDC callback address %s is already in use (%v) — set a different auth.oidc.port for this profile", e.Addr, e.Err) +} +func (e PortInUseError) Unwrap() error { return e.Err } diff --git a/internal/auth/oidc/listener.go b/internal/auth/oidc/listener.go new file mode 100644 index 0000000..40997d6 --- /dev/null +++ b/internal/auth/oidc/listener.go @@ -0,0 +1,34 @@ +package oidc + +import ( + "net" + "net/http" + "sync" + + "github.com/hashicorp/vault/api" +) + +// result is what the callback handler sends once the exchange with Vault +// has concluded (success or failure). +type result struct { + secret *api.Secret + err error +} + +// startServer serves the OIDC callback on ln using a private ServeMux (see +// Flow.Login's doc for why not http.DefaultServeMux) and returns a channel +// that receives exactly one result. The server keeps running until the +// caller calls Shutdown — Flow.Login does this in its deferred cleanup +// regardless of outcome, which is what makes an immediate retry after a +// completed login not hit "address already in use". +func (f *Flow) startServer(ln net.Listener, c *api.Client) (*http.Server, <-chan result) { + done := make(chan result, 1) + var once sync.Once + + mux := http.NewServeMux() + mux.HandleFunc(f.cfg.CallbackPath, f.handler(c, done, &once)) + + srv := &http.Server{Handler: mux} + go func() { _ = srv.Serve(ln) }() + return srv, done +} diff --git a/internal/auth/oidc/nonce.go b/internal/auth/oidc/nonce.go new file mode 100644 index 0000000..6c7f134 --- /dev/null +++ b/internal/auth/oidc/nonce.go @@ -0,0 +1,26 @@ +package oidc + +import ( + "crypto/rand" + "fmt" + "math/big" +) + +const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + +// randomNonce returns an n-character base62 string from crypto/rand, used +// for client_nonce. Deliberately not a dependency on +// github.com/hashicorp/go-secure-stdlib/base62 — this is the same +// crypto/rand-backed generation in about ten lines. +func randomNonce(n int) string { + b := make([]byte, n) + max := big.NewInt(int64(len(base62Alphabet))) + for i := range b { + idx, err := rand.Int(rand.Reader, max) + if err != nil { + panic(fmt.Sprintf("oidc: crypto/rand unavailable: %v", err)) + } + b[i] = base62Alphabet[idx.Int64()] + } + return string(b) +} diff --git a/internal/auth/oidc/pages.go b/internal/auth/oidc/pages.go new file mode 100644 index 0000000..a145499 --- /dev/null +++ b/internal/auth/oidc/pages.go @@ -0,0 +1,24 @@ +package oidc + +import ( + "fmt" + "html" +) + +// successHTML/errorHTML are served back to the browser tab, never to +// stdout/stderr or any log — the request they respond to may still carry +// the authorization code in its query string. +func successHTML() []byte { + return []byte(`vault-tui + +

Signed in

You can close this tab and return to vault-tui.

+`) +} + +func errorHTML(err error) []byte { + msg := html.EscapeString(err.Error()) + return []byte(fmt.Sprintf(`vault-tui + +

Sign-in failed

%s

Return to vault-tui and try again.

+`, msg)) +} diff --git a/internal/auth/prefill.go b/internal/auth/prefill.go new file mode 100644 index 0000000..531d7a9 --- /dev/null +++ b/internal/auth/prefill.go @@ -0,0 +1,100 @@ +package auth + +import ( + "fmt" + "os" + "strings" +) + +// EnvAuthParamPrefix + strings.ToUpper(Field.Name) is consulted by Prefill +// alongside each Field's own EnvFallback list, so any field can be +// overridden from the environment even if the method author didn't think +// to name a specific var for it. +const EnvAuthParamPrefix = "VAULT_TUI_AUTH_" + +// PrefillSource is everything Prefill draws from, ordered highest priority +// first: CLIArgs > env fallback / VAULT_TUI_AUTH_* > ConfigArgs > Field.Default. +type PrefillSource struct { + CLIArgs map[string]string // `vault-tui login -method=x role=eng` + ConfigArgs map[string]string // profile.auth.params + LookupEnv func(string) (string, bool) +} + +func (s PrefillSource) lookupEnv(key string) (string, bool) { + if s.LookupEnv != nil { + return s.LookupEnv(key) + } + return os.LookupEnv(key) +} + +// Prefill resolves each of m's fields' initial value from src, in +// precedence order: CLIArgs > Field.EnvFallback > VAULT_TUI_AUTH_ > +// ConfigArgs > Field.Default. +func Prefill(m Method, src PrefillSource) Credentials { + out := Credentials{} + for _, f := range m.Fields() { + key := f.ConfigKey + if key == "" { + key = f.Name + } + + if v, ok := src.CLIArgs[f.Name]; ok && v != "" { + out[f.Name] = v + continue + } + found := false + for _, envKey := range f.EnvFallback { + if v, ok := src.lookupEnv(envKey); ok && v != "" { + out[f.Name] = v + found = true + break + } + } + if found { + continue + } + if v, ok := src.lookupEnv(EnvAuthParamPrefix + strings.ToUpper(f.Name)); ok && v != "" { + out[f.Name] = v + continue + } + if v, ok := src.ConfigArgs[key]; ok && v != "" { + out[f.Name] = v + continue + } + if f.Default != "" { + out[f.Name] = f.Default + } + } + return out +} + +// Missing returns the Required fields that are still empty after Prefill. +// The TUI renders a form for exactly these; a headless, TTY-attached CLI +// prompts for them; a headless, non-TTY CLI should treat a non-empty result +// as a hard error (never block on stdin). +func Missing(m Method, creds Credentials) []Field { + var out []Field + for _, f := range m.Fields() { + if f.Required && !creds.Has(f.Name) { + out = append(out, f) + } + } + return out +} + +// Validate runs every Field.Validate and checks Required, returning the +// first error encountered. +func Validate(m Method, creds Credentials) error { + for _, f := range m.Fields() { + v, ok := creds[f.Name] + if f.Required && (!ok || v == "") { + return fmt.Errorf("missing required field %q", f.Name) + } + if ok && f.Validate != nil { + if err := f.Validate(v); err != nil { + return fmt.Errorf("field %q: %w", f.Name, err) + } + } + } + return nil +} diff --git a/internal/auth/raw.go b/internal/auth/raw.go new file mode 100644 index 0000000..bfd201c --- /dev/null +++ b/internal/auth/raw.go @@ -0,0 +1,59 @@ +package auth + +import ( + "context" + "crypto/rand" + "errors" + "fmt" + "math/big" + "path" + + "github.com/hashicorp/vault/api" +) + +var errNoAuth = errors.New("empty response from credential provider") + +// loginWrite performs POST auth// and normalises errors. +// This single helper backs every "raw" method (see the method table in the +// design doc) — the official api/auth/{userpass,approle,ldap,kubernetes} +// submodules add nothing over this plus Field.EnvFallback. +func loginWrite(ctx context.Context, c *api.Client, mount, suffix string, data map[string]interface{}) (*api.Secret, error) { + p := path.Join("auth", mount, suffix) + sec, err := c.Logical().WriteWithContext(ctx, p, data) + if err != nil { + return nil, fmt.Errorf("login at %s: %w", p, err) + } + if sec == nil || sec.Auth == nil || sec.Auth.ClientToken == "" { + return nil, fmt.Errorf("%s: %w", p, errNoAuth) + } + return sec, nil +} + +// mountOf returns req.Mount if set, else def. +func mountOf(req Request, def string) string { + if req.Mount != "" { + return req.Mount + } + return def +} + +const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + +// randomNonce returns an n-character base62 string from crypto/rand. Used +// for OIDC's client_nonce and Okta's verify nonce — anywhere Vault's own +// CLI uses go-secure-stdlib/base62.Random, which this deliberately does not +// depend on (it is ~10 lines backed by the same crypto/rand primitive). +func randomNonce(n int) string { + b := make([]byte, n) + max := big.NewInt(int64(len(base62Alphabet))) + for i := range b { + idx, err := rand.Int(rand.Reader, max) + if err != nil { + // crypto/rand failing is fatal for anything security-sensitive; + // panic rather than silently degrade nonce quality. + panic(fmt.Sprintf("auth: crypto/rand unavailable: %v", err)) + } + b[i] = base62Alphabet[idx.Int64()] + } + return string(b) +} diff --git a/internal/auth/registry.go b/internal/auth/registry.go new file mode 100644 index 0000000..417decb --- /dev/null +++ b/internal/auth/registry.go @@ -0,0 +1,86 @@ +package auth + +import "sort" + +// Registry holds every known Method, plus any that are named but +// unavailable in this build (see the no_cloud build tag). +type Registry struct { + methods map[string]Method + unavailable map[string]string // name -> reason +} + +var defaultRegistry = &Registry{ + methods: map[string]Method{}, + unavailable: map[string]string{}, +} + +// register adds m to the default registry. Called from each method file's +// init(), and from the cloud-methods files behind their build tags. +func register(ms ...Method) { + for _, m := range ms { + defaultRegistry.methods[m.Name()] = m + } +} + +// Register is the exported form of register, for methods that live in a +// package auth cannot import without a cycle (internal/auth/oidc imports +// auth for the Method interface itself, so it cannot self-register via +// init() the way the raw methods in this package do). Callers that import +// both packages — cmd/vault-tui, internal/cli — call this once at startup. +func Register(ms ...Method) { register(ms...) } + +// registerUnavailable records a method name that exists conceptually but +// was compiled out (e.g. -tags no_cloud), so pickers can show it greyed out +// with a reason instead of it silently vanishing. +func registerUnavailable(reason string, names ...string) { + for _, n := range names { + defaultRegistry.unavailable[n] = reason + } +} + +// Default returns the process-wide method registry, populated by every auth +// package file's init(). +func Default() *Registry { return defaultRegistry } + +// Get looks up a method by its stable name ("oidc", "userpass", ...). +func (r *Registry) Get(name string) (Method, bool) { + m, ok := r.methods[name] + return m, ok +} + +// Unavailable returns the reason a compiled-out method is unavailable, if any. +func (r *Registry) Unavailable(name string) (string, bool) { + reason, ok := r.unavailable[name] + return reason, ok +} + +// UnavailableAll returns every compiled-out method name mapped to its +// reason, for pickers that want to list them (greyed out) alongside the +// methods that are actually usable in this build. +func (r *Registry) UnavailableAll() map[string]string { + out := make(map[string]string, len(r.unavailable)) + for name, reason := range r.unavailable { + out[name] = reason + } + return out +} + +// Names returns every registered method name, sorted. +func (r *Registry) Names() []string { + out := make([]string, 0, len(r.methods)) + for n := range r.methods { + out = append(out, n) + } + sort.Strings(out) + return out +} + +// All returns every registered Method, sorted by name. +func (r *Registry) All() []Method { + names := r.Names() + out := make([]Method, 0, len(names)) + for _, n := range names { + out = append(out, r.methods[n]) + } + return out +} diff --git a/internal/cli/app.go b/internal/cli/app.go new file mode 100644 index 0000000..c4019ba --- /dev/null +++ b/internal/cli/app.go @@ -0,0 +1,233 @@ +// Package cli wires urfave/cli/v3 commands around internal/config, +// internal/vault, internal/token, and internal/auth so every operation +// (login, list, read, write, status, logout, and the "ui" command that +// launches the Bubbletea TUI) is available both from the terminal directly +// and, unchanged, from inside the TUI's screens. +package cli + +import ( + "context" + "fmt" + "os" + + "github.com/hashicorp/vault/api" + "github.com/urfave/cli/v3" + + "git.morlana.online/f.weber/vault-tui/internal/auth" + "git.morlana.online/f.weber/vault-tui/internal/config" + "git.morlana.online/f.weber/vault-tui/internal/token" + vaultsvc "git.morlana.online/f.weber/vault-tui/internal/vault" +) + +// App is the resolved, shared state every command's Action reads from. It +// is built once in the root Command's Before hook and threaded through +// context.Context (see appKey), which is the idiomatic urfave/cli/v3 +// pattern since BeforeFunc returns the (possibly-derived) context that +// subsequent Action funcs receive. +type App struct { + ConfigPath string + File *config.File + Settings *config.Settings + Client *api.Client // unauthenticated until EnsureLoggedIn/SetToken + Store token.Store + + // Overrides is the flag layer bootstrap resolved Settings with. Kept + // around so SwitchProfile can re-run config.Resolve for a different + // profile with identical flag/env precedence. + Overrides config.Overrides +} + +type appKeyType struct{} + +var appKey = appKeyType{} + +func appFrom(ctx context.Context) (*App, error) { + a, ok := ctx.Value(appKey).(*App) + if !ok || a == nil { + return nil, fmt.Errorf("internal error: app not initialized") + } + return a, nil +} + +// overridesFromCommand reads the global flags declared in root.go into a +// config.Overrides — the "flag" layer of config.Resolve's precedence chain. +func overridesFromCommand(cmd *cli.Command) config.Overrides { + ov := config.Overrides{ + Profile: cmd.String("profile"), + Address: cmd.String("address"), + Namespace: cmd.String("namespace"), + Token: cmd.String("token"), + CACert: cmd.String("ca-cert"), + ClientCert: cmd.String("client-cert"), + ClientKey: cmd.String("client-key"), + NoEnv: cmd.Bool("no-env"), + NoColor: cmd.Bool("no-color"), + } + if cmd.IsSet("tls-skip-verify") { + v := cmd.Bool("tls-skip-verify") + ov.SkipVerify = &v + } + if cmd.IsSet("read-only") { + v := cmd.Bool("read-only") + ov.ReadOnly = &v + } else if cmd.IsSet("write") { + v := !cmd.Bool("write") + ov.ReadOnly = &v + } + return ov +} + +// bootstrap builds an *App from global flags: loads the config file, +// resolves Settings, and builds an (unauthenticated) *api.Client. It does +// NOT resolve or attach a token — see EnsureLoggedIn, which most commands +// call explicitly so that `vault-tui config init` and similar can run +// before any Vault connectivity exists. +func bootstrap(cmd *cli.Command) (*App, error) { + path, err := config.ResolvePath(cmd.String("config")) + if err != nil { + return nil, err + } + + // First-run UX: if there is genuinely no config yet, an interactive + // session gets an inline wizard instead of a wall of "no profile + // configured" errors — but never for `config ...` itself (that + // subtree has its own explicit `init`) and never in a non-interactive + // context (CI, pipes), where the tool must stay driven entirely by + // flags/env instead of blocking on stdin. + leaf := cmd.Args().First() + if !config.Exists(path) && IsInteractive() && leaf != "config" { + if _, err := runWizard(path); err != nil { + return nil, err + } + } + + file, err := config.Load(path) + if err != nil { + return nil, err + } + ov := overridesFromCommand(cmd) + settings, err := config.Resolve(file, ov.Profile, ov, config.OSEnviron) + if err != nil { + return nil, err + } + client, err := vaultsvc.NewClient(settings) + if err != nil { + return nil, err + } + store, err := storeFor(settings) + if err != nil { + return nil, err + } + return &App{ConfigPath: path, File: file, Settings: settings, Client: client, Store: store, Overrides: ov}, nil +} + +// SwitchProfile re-resolves Settings for a different profile using the same +// flag/env overrides bootstrap ran with, and rebuilds Client and Store to +// match — what lets the TUI's profile picker switch profiles in place +// instead of requiring a restart. The caller is responsible for driving a +// fresh login afterwards; this does not touch authentication state beyond +// replacing the (unauthenticated) Client. +func (a *App) SwitchProfile(profile string) error { + ov := a.Overrides + ov.Profile = profile + settings, err := config.Resolve(a.File, profile, ov, config.OSEnviron) + if err != nil { + return err + } + client, err := vaultsvc.NewClient(settings) + if err != nil { + return err + } + store, err := storeFor(settings) + if err != nil { + return err + } + a.Settings, a.Client, a.Store = settings, client, store + return nil +} + +func storeFor(s *config.Settings) (token.Store, error) { + switch s.Token.Storage { + case "none": + return token.NewNoneStore(), nil + case "profile": + if s.Token.File != "" { + return token.NewFileStore(expandHome(s.Token.File)), nil + } + dir, err := config.StateDir() + if err != nil { + return nil, err + } + return token.NewProfileStore(dir, s.Profile), nil + default: // "vault-cli" or unset + return token.NewVaultCLIStore() + } +} + +func expandHome(p string) string { + if len(p) >= 2 && p[:2] == "~/" { + if home, err := os.UserHomeDir(); err == nil { + return home + p[1:] + } + } + return p +} + +// EnsureLoggedIn resolves a token via internal/token.Resolve (flag > env > +// config > store), validates it with token.Lookup, and sets it on a.Client. +// If no token can be resolved at all, it returns a plain error telling the +// caller to run `vault-tui login` — headless commands never block on stdin +// here; interactive login is `login`'s job, not every command's. +func (a *App) EnsureLoggedIn(ctx context.Context, flagToken string) (*token.Info, error) { + envTok, _ := config.OSEnviron(config.EnvToken) + resolved, err := token.Resolve(ctx, token.Options{ + Flag: flagToken, + Env: envTok, + Profile: a.File.Profiles[a.Settings.Profile], + Store: a.Store, + }) + if err != nil { + return nil, err + } + if resolved.Token == "" { + return nil, fmt.Errorf("no Vault token found (checked --token, VAULT_TOKEN, and %s) — run `vault-tui login`", a.Store.Kind()) + } + a.Client.SetToken(resolved.Token) + + info, err := token.Lookup(ctx, a.Client) + if err != nil { + a.Client.ClearToken() + return nil, fmt.Errorf("resolved a token via %s but Vault rejected it: %w", resolved.Origin, err) + } + return info, nil +} + +// Service builds the KV-facing Service around an already-authenticated +// client. Call after EnsureLoggedIn. +func (a *App) Service() *vaultsvc.Service { + return vaultsvc.New(a.Client, a.Settings.ReadOnly) +} + +// Method resolves an auth.Method by name, special-casing "oidc" so that +// per-profile OIDC settings (port, callback host, timeouts — see +// config.OIDCOpts) are honoured even though the globally registered "oidc" +// entry (for picker/help listing) only carries library defaults. +func (a *App) Method(name string) (auth.Method, error) { + if name == "" { + name = a.Settings.Auth.Method + } + if name == "" { + return nil, fmt.Errorf("no auth method configured for profile %q (set auth.method or pass -method)", a.Settings.Profile) + } + if name == "oidc" { + return a.oidcMethod(), nil + } + m, ok := auth.Default().Get(name) + if !ok { + if reason, unavail := auth.Default().Unavailable(name); unavail { + return nil, fmt.Errorf("auth method %q is unavailable in this build: %s", name, reason) + } + return nil, fmt.Errorf("unknown auth method %q", name) + } + return m, nil +} diff --git a/internal/cli/config.go b/internal/cli/config.go new file mode 100644 index 0000000..82a14b8 --- /dev/null +++ b/internal/cli/config.go @@ -0,0 +1,84 @@ +package cli + +import ( + "context" + "fmt" + "os" + + "github.com/urfave/cli/v3" + + "git.morlana.online/f.weber/vault-tui/internal/config" +) + +func configCommand() *cli.Command { + return &cli.Command{ + Name: "config", + Usage: "manage the vault-tui config file", + Commands: []*cli.Command{ + { + Name: "init", + Usage: "interactively create a new config file", + Action: func(ctx context.Context, cmd *cli.Command) error { + a, err := appFrom(ctx) + if err != nil { + return err + } + if config.Exists(a.ConfigPath) && !askOverwrite(a.ConfigPath) { + return nil + } + if !IsInteractive() { + return fmt.Errorf("config init requires an interactive terminal") + } + _, err = runWizard(a.ConfigPath) + return err + }, + }, + { + Name: "path", + Usage: "print the resolved config file path", + Action: func(ctx context.Context, cmd *cli.Command) error { + a, err := appFrom(ctx) + if err != nil { + return err + } + fmt.Println(a.ConfigPath) + return nil + }, + }, + { + Name: "show", + Usage: "print the fully resolved settings for the active profile", + Flags: []cli.Flag{&cli.BoolFlag{Name: "json"}}, + Action: func(ctx context.Context, cmd *cli.Command) error { + a, err := appFrom(ctx) + if err != nil { + return err + } + if cmd.Bool("json") { + return printJSON(os.Stdout, a.Settings) + } + s := a.Settings + out := map[string]string{ + "profile": s.Profile, + "address": s.Address, + "namespace": s.Namespace, + "read_only": fmt.Sprint(s.ReadOnly), + "auth.method": s.Auth.Method, + "auth.mount": s.Auth.Mount, + "tls.ca_cert": s.CACert, + "tls.skip_verify": fmt.Sprint(s.SkipVerify), + } + printKV(os.Stdout, out) + return nil + }, + }, + }, + } +} + +func askOverwrite(path string) bool { + fmt.Fprintf(os.Stderr, "%s already exists. Overwrite? (y/N): ", path) + var resp string + fmt.Fscanln(os.Stdin, &resp) + return resp == "y" || resp == "yes" +} diff --git a/internal/cli/delete.go b/internal/cli/delete.go new file mode 100644 index 0000000..3577b42 --- /dev/null +++ b/internal/cli/delete.go @@ -0,0 +1,59 @@ +package cli + +import ( + "context" + "fmt" + + "github.com/urfave/cli/v3" + + "git.morlana.online/f.weber/vault-tui/internal/vault" +) + +func deleteCommand() *cli.Command { + return &cli.Command{ + Name: "delete", + Usage: "delete a secret (soft-delete for KV v2; irreversible for KV v1/cubbyhole)", + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.BoolFlag{Name: "destroy", Usage: "KV v2: permanently destroy instead of soft-delete (irreversible)"}, + &cli.BoolFlag{Name: "metadata", Usage: "KV v2: delete all versions and metadata (irreversible)"}, + &cli.IntSliceFlag{Name: "versions", Usage: "KV v2: specific versions to target (default: current)"}, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + a, err := appFrom(ctx) + if err != nil { + return err + } + if cmd.Args().Len() == 0 { + return fmt.Errorf("usage: vault-tui delete ") + } + if _, err := a.EnsureLoggedIn(ctx, cmd.String("token")); err != nil { + return err + } + svc := a.Service() + if svc.ReadOnly { + return fmt.Errorf("refusing to delete: vault-tui is in read-only mode (pass --write to override)") + } + mount, rel, err := splitMountPath(ctx, svc, cmd.Args().First()) + if err != nil { + return err + } + + op := vault.OpSoftDelete + if mount.Kind != vault.EngineKVv2 { + op = vault.OpDeleteV1 + } else if cmd.Bool("metadata") { + op = vault.OpDeleteMetadata + } else if cmd.Bool("destroy") { + op = vault.OpDestroy + } + + ack, err := svc.KV.Delete(ctx, mount, rel, op, cmd.IntSlice("versions")) + if err != nil { + return err + } + fmt.Printf("deleted %s (op=%v)\n", ack.Path, ack.Op) + return nil + }, + } +} diff --git a/internal/cli/kv_helpers.go b/internal/cli/kv_helpers.go new file mode 100644 index 0000000..1275b56 --- /dev/null +++ b/internal/cli/kv_helpers.go @@ -0,0 +1,39 @@ +package cli + +import ( + "context" + "fmt" + "strings" + + "git.morlana.online/f.weber/vault-tui/internal/vault" +) + +// splitMountPath finds which configured mount a full logical path like +// "secret/team/prod/db" belongs to, and returns the mount plus the +// remaining path relative to it ("team/prod/db"). Mirrors how `vault kv` +// itself resolves a bare path against sys/internal/ui/mounts. +func splitMountPath(ctx context.Context, svc *vault.Service, full string) (vault.Mount, string, error) { + full = strings.TrimPrefix(full, "/") + mounts, err := svc.Mounts(ctx) + if err != nil { + return vault.Mount{}, "", fmt.Errorf("listing mounts: %w", err) + } + var best vault.Mount + for _, m := range mounts { + mp := strings.TrimSuffix(m.Path, "/") + if full == mp || strings.HasPrefix(full, mp+"/") { + if len(mp) > len(strings.TrimSuffix(best.Path, "/")) { + best = m + } + } + } + if best.Path == "" { + return vault.Mount{}, "", fmt.Errorf("no configured mount matches %q", full) + } + if !best.Supported() { + return vault.Mount{}, "", fmt.Errorf("mount %q (type %s) is not supported by vault-tui", best.Path, best.Type) + } + rel := strings.TrimPrefix(full, strings.TrimSuffix(best.Path, "/")) + rel = strings.TrimPrefix(rel, "/") + return best, rel, nil +} diff --git a/internal/cli/list.go b/internal/cli/list.go new file mode 100644 index 0000000..873378e --- /dev/null +++ b/internal/cli/list.go @@ -0,0 +1,50 @@ +package cli + +import ( + "context" + "fmt" + "os" + + "github.com/urfave/cli/v3" +) + +func listCommand() *cli.Command { + return &cli.Command{ + Name: "list", + Aliases: []string{"ls"}, + Usage: "list secrets under a path (e.g. `vault-tui list secret/team/`)", + ArgsUsage: "", + Flags: []cli.Flag{&cli.BoolFlag{Name: "json"}}, + Action: func(ctx context.Context, cmd *cli.Command) error { + a, err := appFrom(ctx) + if err != nil { + return err + } + if cmd.Args().Len() == 0 { + return fmt.Errorf("usage: vault-tui list ") + } + if _, err := a.EnsureLoggedIn(ctx, cmd.String("token")); err != nil { + return err + } + svc := a.Service() + mount, rel, err := splitMountPath(ctx, svc, cmd.Args().First()) + if err != nil { + return err + } + l, err := svc.KV.List(ctx, mount, rel) + if err != nil { + return err + } + if cmd.Bool("json") { + return printJSON(os.Stdout, l) + } + for _, d := range l.Dirs { + fmt.Println(d) + } + for _, f := range l.Leaves { + fmt.Println(f) + } + return nil + }, + } +} diff --git a/internal/cli/login.go b/internal/cli/login.go new file mode 100644 index 0000000..fdaa302 --- /dev/null +++ b/internal/cli/login.go @@ -0,0 +1,110 @@ +package cli + +import ( + "context" + "fmt" + "os" + + "github.com/urfave/cli/v3" + + "git.morlana.online/f.weber/vault-tui/internal/auth" +) + +func loginCommand() *cli.Command { + return &cli.Command{ + Name: "login", + Usage: "authenticate to Vault and save the resulting token", + ArgsUsage: "[key=value ...]", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "method", Usage: "auth method name (default: profile's auth.method)"}, + &cli.StringFlag{Name: "mount", Usage: "auth mount path (default: profile's auth.mount, or the method's default)"}, + &cli.BoolFlag{Name: "no-store", Usage: "print the token instead of saving it"}, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + a, err := appFrom(ctx) + if err != nil { + return err + } + m, err := a.Method(cmd.String("method")) + if err != nil { + return err + } + mount := cmd.String("mount") + if mount == "" { + mount = a.Settings.Auth.Mount + } + if mount == "" { + mount = m.DefaultMount() + } + + cliArgs, err := parseKVArgs(cmd.Args().Slice()) + if err != nil { + return err + } + creds := auth.Prefill(m, auth.PrefillSource{ + CLIArgs: cliArgs, + ConfigArgs: a.Settings.Auth.Params, + }) + + if missing := auth.Missing(m, creds); len(missing) > 0 { + if !IsInteractive() { + names := make([]string, 0, len(missing)) + for _, f := range missing { + names = append(names, f.Name) + } + return fmt.Errorf("missing required credentials for method %q: %v (set via VAULT_TUI_AUTH_, a method-specific env var, key=value args, or run interactively)", m.Name(), names) + } + if err := promptMissing(m, creds); err != nil { + return err + } + } + if err := auth.Validate(m, creds); err != nil { + return err + } + + events := make(chan auth.Event, 16) + go drainEvents(events) + req := auth.Request{Mount: mount, Namespace: a.Settings.Namespace, Creds: creds, Events: events} + + sec, err := m.Login(ctx, a.Client, req) + close(events) + if err != nil { + return fmt.Errorf("login failed: %w", err) + } + result, err := auth.NewResult(sec, a.Settings.Namespace) + if err != nil { + return err + } + + if cmd.Bool("no-store") { + fmt.Println(result.Token) + return nil + } + if err := a.Store.Store(ctx, result.Token); err != nil { + return fmt.Errorf("saving token to %s: %w", a.Store.Location(), err) + } + fmt.Fprintf(os.Stderr, "Success! Token saved via %s (%s).\n", a.Store.Kind(), a.Store.Location()) + if result.TTL > 0 { + fmt.Fprintf(os.Stderr, "token ttl: %s policies: %v\n", result.TTL, result.Policies) + } + return nil + }, + } +} + +// drainEvents prints login progress (the OIDC URL, Okta push prompts, ...) +// to stderr as it arrives. Used by every headless command that can trigger +// a multi-step login; the TUI instead pumps these into its own model (see +// internal/ui/app — not yet wired here). +func drainEvents(events <-chan auth.Event) { + for e := range events { + switch e.Kind { + case auth.EventOpenURL: + fmt.Fprintf(os.Stderr, "\nOpen this URL to continue:\n\n %s\n\n", e.URL) + case auth.EventWarning: + fmt.Fprintf(os.Stderr, "warning: %s\n", e.Message) + default: + fmt.Fprintf(os.Stderr, "%s\n", e.Message) + } + } +} diff --git a/internal/cli/logout.go b/internal/cli/logout.go new file mode 100644 index 0000000..9cc3a9a --- /dev/null +++ b/internal/cli/logout.go @@ -0,0 +1,46 @@ +package cli + +import ( + "context" + "fmt" + "os" + + "github.com/urfave/cli/v3" + + "git.morlana.online/f.weber/vault-tui/internal/token" +) + +func logoutCommand() *cli.Command { + return &cli.Command{ + Name: "logout", + Usage: "forget the saved token (does not revoke it in Vault unless -revoke is given)", + Flags: []cli.Flag{ + &cli.BoolFlag{Name: "revoke", Usage: "also revoke the token in Vault"}, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + a, err := appFrom(ctx) + if err != nil { + return err + } + revoke := cmd.Bool("revoke") + if p := a.File.Profiles[a.Settings.Profile]; p != nil && p.Token.RevokeOnLogout != nil { + revoke = revoke || *p.Token.RevokeOnLogout + } + if revoke { + if _, err := a.EnsureLoggedIn(ctx, cmd.String("token")); err != nil { + fmt.Fprintf(os.Stderr, "warning: could not verify token before revoking: %v\n", err) + } + if err := token.RevokeSelf(ctx, a.Client, a.Store); err != nil { + return fmt.Errorf("revoking token: %w", err) + } + fmt.Println("Token revoked and forgotten.") + return nil + } + if err := a.Store.Erase(ctx); err != nil { + return fmt.Errorf("erasing token from %s: %w", a.Store.Location(), err) + } + fmt.Println("Token forgotten (not revoked in Vault).") + return nil + }, + } +} diff --git a/internal/cli/oidc.go b/internal/cli/oidc.go new file mode 100644 index 0000000..b297933 --- /dev/null +++ b/internal/cli/oidc.go @@ -0,0 +1,47 @@ +package cli + +import ( + "git.morlana.online/f.weber/vault-tui/internal/auth" + "git.morlana.online/f.weber/vault-tui/internal/auth/oidc" +) + +// init registers a default-configured OIDC method so it appears in +// auth.Default() for listing/help purposes. app.Method special-cases +// "oidc" to build a fresh oidc.Method from the profile's actual settings +// (see oidcMethod below) instead of using this registered instance, because +// OIDC's port/callback/timeout are meaningfully per-profile. +func init() { + auth.Register(oidc.Method{}) +} + +func (a *App) oidcMethod() auth.Method { + o := a.Settings.Auth.OIDC + cfg := oidc.Config{ + Mount: a.Settings.Auth.Mount, + Role: a.Settings.Auth.Params["role"], + ListenAddress: o.ListenAddress, + CallbackMethod: o.CallbackMethod, + CallbackHost: o.CallbackHost, + CallbackPath: o.CallbackPath, + BrowserCommand: a.Settings.BrowserCommand, + } + if o.Port != nil { + cfg.Port = *o.Port + } + if o.CallbackPort != nil { + cfg.CallbackPort = *o.CallbackPort + } + if o.SkipBrowser != nil { + cfg.SkipBrowser = *o.SkipBrowser + } + if o.AbortOnBrowserError != nil { + cfg.AbortOnBrowserError = *o.AbortOnBrowserError + } + if o.Timeout != nil { + cfg.Timeout = *o.Timeout + } + if cfg.Mount == "" { + cfg.Mount = "oidc" + } + return oidc.Method{Cfg: cfg} +} diff --git a/internal/cli/output.go b/internal/cli/output.go new file mode 100644 index 0000000..d55f8a4 --- /dev/null +++ b/internal/cli/output.go @@ -0,0 +1,47 @@ +package cli + +import ( + "encoding/json" + "fmt" + "io" + "sort" + "strings" +) + +// printJSON is the shared -format=json path for every headless command. +func printJSON(w io.Writer, v interface{}) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(v) +} + +// printKV renders a map as an aligned "key value" table, sorted by key. +// Used by `status` and `read` in their default (non-JSON) output. +func printKV(w io.Writer, m map[string]string) { + keys := make([]string, 0, len(m)) + width := 0 + for k := range m { + keys = append(keys, k) + if len(k) > width { + width = len(k) + } + } + sort.Strings(keys) + for _, k := range keys { + fmt.Fprintf(w, "%-*s %s\n", width, k, m[k]) + } +} + +// parseKVArgs turns ["key=value", "other=1"] positional args into a map, +// used by `write` and as auth method params on `login`. +func parseKVArgs(args []string) (map[string]string, error) { + out := map[string]string{} + for _, a := range args { + k, v, ok := strings.Cut(a, "=") + if !ok { + return nil, fmt.Errorf("expected key=value, got %q", a) + } + out[k] = v + } + return out, nil +} diff --git a/internal/cli/prompt.go b/internal/cli/prompt.go new file mode 100644 index 0000000..029b287 --- /dev/null +++ b/internal/cli/prompt.go @@ -0,0 +1,57 @@ +package cli + +import ( + "bufio" + "fmt" + "os" + "strings" + + "golang.org/x/term" + + "git.morlana.online/f.weber/vault-tui/internal/auth" +) + +// promptMissing fills in any of m's still-missing required fields by +// prompting on the controlling TTY. It never blocks when stdin is not a +// terminal (e.g. CI/pipes) — callers should check IsInteractive first and +// treat a non-empty Missing() list as a hard error in that case instead of +// calling this. +func promptMissing(m auth.Method, creds auth.Credentials) error { + missing := auth.Missing(m, creds) + if len(missing) == 0 { + return nil + } + reader := bufio.NewReader(os.Stdin) + for _, f := range missing { + if f.Kind == auth.FieldSecret { + v, err := readPassword(f.Label) + if err != nil { + return err + } + creds[f.Name] = v + continue + } + fmt.Fprintf(os.Stderr, "%s: ", f.Label) + line, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("reading %s: %w", f.Label, err) + } + creds[f.Name] = strings.TrimSpace(line) + } + return nil +} + +func readPassword(label string) (string, error) { + fmt.Fprintf(os.Stderr, "%s (will be hidden): ", label) + b, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Fprintln(os.Stderr) + if err != nil { + return "", fmt.Errorf("reading %s: %w", label, err) + } + return strings.TrimSpace(string(b)), nil +} + +// IsInteractive reports whether stdin is a terminal we can prompt on. +func IsInteractive() bool { + return term.IsTerminal(int(os.Stdin.Fd())) +} diff --git a/internal/cli/read.go b/internal/cli/read.go new file mode 100644 index 0000000..542cc8a --- /dev/null +++ b/internal/cli/read.go @@ -0,0 +1,66 @@ +package cli + +import ( + "context" + "fmt" + "os" + + "github.com/urfave/cli/v3" +) + +func readCommand() *cli.Command { + return &cli.Command{ + Name: "read", + Usage: "read a secret (e.g. `vault-tui read secret/team/prod/db`)", + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.BoolFlag{Name: "json"}, + &cli.IntFlag{Name: "version", Usage: "KV v2 version to read (default: latest)"}, + &cli.StringFlag{Name: "field", Usage: "print only this field's raw value"}, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + a, err := appFrom(ctx) + if err != nil { + return err + } + if cmd.Args().Len() == 0 { + return fmt.Errorf("usage: vault-tui read ") + } + if _, err := a.EnsureLoggedIn(ctx, cmd.String("token")); err != nil { + return err + } + svc := a.Service() + mount, rel, err := splitMountPath(ctx, svc, cmd.Args().First()) + if err != nil { + return err + } + sec, err := svc.KV.Read(ctx, mount, rel, int(cmd.Int("version"))) + if err != nil { + return err + } + if sec == nil { + return fmt.Errorf("no secret found at %s", cmd.Args().First()) + } + if field := cmd.String("field"); field != "" { + v, ok := sec.Data[field] + if !ok { + return fmt.Errorf("field %q not present", field) + } + fmt.Println(v) + return nil + } + if cmd.Bool("json") { + return printJSON(os.Stdout, sec) + } + out := map[string]string{} + for k, v := range sec.Data { + out[k] = fmt.Sprint(v) + } + printKV(os.Stdout, out) + if sec.Version > 0 { + fmt.Printf("\nversion: %d\n", sec.Version) + } + return nil + }, + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go new file mode 100644 index 0000000..af0e532 --- /dev/null +++ b/internal/cli/root.go @@ -0,0 +1,60 @@ +package cli + +import ( + "context" + + "github.com/urfave/cli/v3" +) + +// Root builds the full command tree. DefaultCommand launches the TUI when +// vault-tui is invoked with no subcommand, matching the plan's "possible +// without any subcommand" requirement. +func Root(version string) *cli.Command { + root := &cli.Command{ + Name: "vault-tui", + Usage: "Terminal UI for HashiCorp Vault", + Version: version, + DefaultCommand: "ui", + // Deliberately no Sources: cli.EnvVars(...) here: internal/config.Resolve + // (and config.ResolvePath/SelectProfile for --config/--profile) already + // implements the full flag>env>profile>defaults>builtin chain with + // correct per-field Origin tracking. Letting urfave/cli additionally + // populate these flags from the same env vars would make cmd.IsSet() + // indistinguishable between "user passed --address" and "VAULT_ADDR is + // set", corrupting both precedence and the status bar's Origin display. + Flags: []cli.Flag{ + &cli.StringFlag{Name: "config", Usage: "path to config.yaml"}, + &cli.StringFlag{Name: "profile", Aliases: []string{"p"}}, + &cli.StringFlag{Name: "address"}, + &cli.StringFlag{Name: "namespace"}, + &cli.StringFlag{Name: "token"}, + &cli.StringFlag{Name: "ca-cert"}, + &cli.StringFlag{Name: "client-cert"}, + &cli.StringFlag{Name: "client-key"}, + &cli.BoolFlag{Name: "tls-skip-verify"}, + &cli.BoolFlag{Name: "read-only", Usage: "refuse all write operations"}, + &cli.BoolFlag{Name: "write", Usage: "allow write operations (overrides a read-only default)"}, + &cli.BoolFlag{Name: "no-env", Usage: "ignore VAULT_* environment variables"}, + &cli.BoolFlag{Name: "no-color"}, + }, + Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) { + a, err := bootstrap(cmd) + if err != nil { + return ctx, err + } + return context.WithValue(ctx, appKey, a), nil + }, + Commands: []*cli.Command{ + uiCommand(), + loginCommand(), + logoutCommand(), + statusCommand(), + listCommand(), + readCommand(), + writeCommand(), + deleteCommand(), + configCommand(), + }, + } + return root +} diff --git a/internal/cli/status.go b/internal/cli/status.go new file mode 100644 index 0000000..a293059 --- /dev/null +++ b/internal/cli/status.go @@ -0,0 +1,61 @@ +package cli + +import ( + "context" + "fmt" + "os" + + "github.com/urfave/cli/v3" +) + +func statusCommand() *cli.Command { + return &cli.Command{ + Name: "status", + Usage: "show the resolved profile, address, and token status", + Flags: []cli.Flag{ + &cli.BoolFlag{Name: "json"}, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + a, err := appFrom(ctx) + if err != nil { + return err + } + s := a.Settings + out := map[string]string{ + "profile": s.Profile, + "address": fmt.Sprintf("%s (%s)", s.Address, s.OriginOf("address").Key), + "namespace": valueOr(s.Namespace, "(none)"), + "read_only": fmt.Sprint(s.ReadOnly), + "auth_method": valueOr(s.Auth.Method, "(unset)"), + "token_store": fmt.Sprintf("%s (%s)", a.Store.Kind(), a.Store.Location()), + } + + info, err := a.EnsureLoggedIn(ctx, cmd.String("token")) + if err != nil { + out["token"] = fmt.Sprintf("none/invalid: %v", err) + if cmd.Bool("json") { + return printJSON(os.Stdout, out) + } + printKV(os.Stdout, out) + return nil + } + out["token_accessor"] = info.Accessor + out["token_policies"] = fmt.Sprint(info.Policies) + out["token_ttl"] = info.TTL.String() + out["token_renewable"] = fmt.Sprint(info.Renewable) + + if cmd.Bool("json") { + return printJSON(os.Stdout, out) + } + printKV(os.Stdout, out) + return nil + }, + } +} + +func valueOr(v, fallback string) string { + if v == "" { + return fallback + } + return v +} diff --git a/internal/cli/ui.go b/internal/cli/ui.go new file mode 100644 index 0000000..0517f28 --- /dev/null +++ b/internal/cli/ui.go @@ -0,0 +1,35 @@ +package cli + +import ( + "context" + "fmt" + + "github.com/urfave/cli/v3" +) + +// uiCommand launches the Bubbletea TUI. Implemented in internal/ui; wired +// up once that package exists (see runUI, set from cmd/vault-tui/main.go's +// init-time hook to avoid internal/cli importing the TUI toolkit itself and +// thus keeping headless commands buildable/testable without it). +var runUI func(ctx context.Context, a *App) error + +func uiCommand() *cli.Command { + return &cli.Command{ + Name: "ui", + Usage: "launch the terminal UI (default when no subcommand is given)", + Action: func(ctx context.Context, cmd *cli.Command) error { + a, err := appFrom(ctx) + if err != nil { + return err + } + if runUI == nil { + return fmt.Errorf("the TUI is not available in this build") + } + return runUI(ctx, a) + }, + } +} + +// SetUIRunner lets cmd/vault-tui wire the real internal/ui implementation +// into the "ui" command without internal/cli importing a TUI toolkit. +func SetUIRunner(f func(ctx context.Context, a *App) error) { runUI = f } diff --git a/internal/cli/wizard.go b/internal/cli/wizard.go new file mode 100644 index 0000000..3b60fed --- /dev/null +++ b/internal/cli/wizard.go @@ -0,0 +1,107 @@ +package cli + +import ( + "bufio" + "fmt" + "os" + "strings" + + "git.morlana.online/f.weber/vault-tui/internal/auth" + "git.morlana.online/f.weber/vault-tui/internal/config" +) + +// runWizard interactively builds one profile and writes it to path. It is +// deliberately generic — it prompts for everything (address, namespace, +// TLS, auth method and that method's fields) rather than assuming any +// particular Vault deployment, per this project's design goal of not +// hard-coding any environment's specifics into the tool itself. +func runWizard(path string) (*config.File, error) { + r := bufio.NewReader(os.Stdin) + fmt.Fprintln(os.Stderr, "No config found — let's set up a Vault profile (you can edit it later at "+path+").") + + profileName := ask(r, "Profile name", "default") + addrDefault := "https://127.0.0.1:8200" + if v, ok := config.OSEnviron(config.EnvAddress); ok && v != "" { + addrDefault = v + } + address := ask(r, "Vault address", addrDefault) + namespace := ask(r, "Namespace (blank for none)", "") + + prof := &config.Profile{Address: address, Namespace: namespace} + + if askYesNo(r, "Configure custom TLS (CA cert / client cert)?", false) { + prof.TLS.CACert = ask(r, "CA certificate path (blank to use system trust)", "") + prof.TLS.ClientCert = ask(r, "Client certificate path (blank for none)", "") + if prof.TLS.ClientCert != "" { + prof.TLS.ClientKey = ask(r, "Client key path", "") + } + if askYesNo(r, "Skip TLS verification (insecure, testing only)?", false) { + t := true + prof.TLS.SkipVerify = &t + } + } + + names := auth.Default().Names() + fmt.Fprintln(os.Stderr, "Available auth methods: "+strings.Join(names, ", ")) + method := ask(r, "Auth method", "oidc") + m, ok := auth.Default().Get(method) + if !ok { + return nil, fmt.Errorf("unknown auth method %q", method) + } + prof.Auth.Method = method + mount := ask(r, "Auth mount path", m.DefaultMount()) + prof.Auth.Mount = mount + + params := map[string]string{} + for _, f := range m.Fields() { + if f.Kind == auth.FieldSecret { + // Secrets are never written to the config file; the user supplies + // them at login time (env var, flag, or interactive prompt). + continue + } + v := ask(r, f.Label, f.Default) + if v != "" { + params[f.Name] = v + } + } + if len(params) > 0 { + prof.Auth.Params = params + } + + file := &config.File{ + Version: config.SchemaVersion, + CurrentProfile: profileName, + Profiles: map[string]*config.Profile{profileName: prof}, + } + if err := config.Save(path, file); err != nil { + return nil, err + } + fmt.Fprintln(os.Stderr, "Saved "+path) + return file, nil +} + +func ask(r *bufio.Reader, label, def string) string { + if def != "" { + fmt.Fprintf(os.Stderr, "%s [%s]: ", label, def) + } else { + fmt.Fprintf(os.Stderr, "%s: ", label) + } + line, _ := r.ReadString('\n') + line = strings.TrimSpace(line) + if line == "" { + return def + } + return line +} + +func askYesNo(r *bufio.Reader, label string, def bool) bool { + d := "y/N" + if def { + d = "Y/n" + } + line := strings.ToLower(ask(r, label+" ("+d+")", "")) + if line == "" { + return def + } + return line == "y" || line == "yes" +} diff --git a/internal/cli/write.go b/internal/cli/write.go new file mode 100644 index 0000000..0f33900 --- /dev/null +++ b/internal/cli/write.go @@ -0,0 +1,69 @@ +package cli + +import ( + "context" + "fmt" + + "github.com/urfave/cli/v3" +) + +func writeCommand() *cli.Command { + return &cli.Command{ + Name: "write", + Usage: "create or update a secret (e.g. `vault-tui write secret/team/prod/db user=admin pass=hunter2`)", + ArgsUsage: " key=value [key=value ...]", + Flags: []cli.Flag{ + &cli.IntFlag{Name: "cas", Usage: "KV v2 check-and-set version (default: current version, if require_cas is on)"}, + &cli.BoolFlag{Name: "force", Usage: "skip check-and-set even if require_cas is on"}, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + a, err := appFrom(ctx) + if err != nil { + return err + } + args := cmd.Args().Slice() + if len(args) < 2 { + return fmt.Errorf("usage: vault-tui write key=value [key=value ...]") + } + if _, err := a.EnsureLoggedIn(ctx, cmd.String("token")); err != nil { + return err + } + svc := a.Service() + if svc.ReadOnly { + return fmt.Errorf("refusing to write: vault-tui is in read-only mode (pass --write to override)") + } + mount, rel, err := splitMountPath(ctx, svc, args[0]) + if err != nil { + return err + } + data, err := parseKVArgs(args[1:]) + if err != nil { + return err + } + iface := make(map[string]interface{}, len(data)) + for k, v := range data { + iface[k] = v + } + + useCAS := a.Settings.RequireCAS && !cmd.Bool("force") + cas := int(cmd.Int("cas")) + if useCAS && !cmd.IsSet("cas") { + current, err := svc.KV.Read(ctx, mount, rel, 0) + if err == nil && current != nil { + cas = current.Version + } + } + + ack, err := svc.KV.Write(ctx, mount, rel, iface, useCAS, cas) + if err != nil { + return err + } + if ack.Version > 0 { + fmt.Printf("wrote %s (version %d)\n", args[0], ack.Version) + } else { + fmt.Printf("wrote %s\n", args[0]) + } + return nil + }, + } +} diff --git a/internal/config/color.go b/internal/config/color.go new file mode 100644 index 0000000..1a9efd3 --- /dev/null +++ b/internal/config/color.go @@ -0,0 +1,40 @@ +package config + +import ( + "fmt" + + "gopkg.in/yaml.v3" +) + +// UnmarshalYAML accepts either a scalar ("#7D56F4") applied to both +// appearances, or a mapping ({light: "#...", dark: "#..."}). +func (c *Color) UnmarshalYAML(n *yaml.Node) error { + switch n.Kind { + case yaml.ScalarNode: + var s string + if err := n.Decode(&s); err != nil { + return err + } + c.Light, c.Dark = s, s + return nil + case yaml.MappingNode: + var pair struct { + Light string `yaml:"light"` + Dark string `yaml:"dark"` + } + if err := n.Decode(&pair); err != nil { + return err + } + c.Light, c.Dark = pair.Light, pair.Dark + return nil + default: + return fmt.Errorf("color must be a string or a {light, dark} mapping, got %v", n.Kind) + } +} + +func (c Color) MarshalYAML() (interface{}, error) { + if c.Light == c.Dark { + return c.Light, nil + } + return map[string]string{"light": c.Light, "dark": c.Dark}, nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..20274a8 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,127 @@ +// Package config defines the vault-tui configuration schema and how it is +// loaded from disk, merged with per-profile defaults, and combined with +// environment variables and CLI flags to produce a fully resolved +// [vault.Settings] (see internal/vault/settings.go and resolve.go in this +// package). +package config + +import "time" + +// SchemaVersion is the only value config.Version currently accepts. +const SchemaVersion = 1 + +// File is the root of ~/.config/vault-tui/config.yaml. +type File struct { + Version int `yaml:"version"` + CurrentProfile string `yaml:"current_profile,omitempty"` + Defaults Profile `yaml:"defaults,omitempty"` + Profiles map[string]*Profile `yaml:"profiles,omitempty"` + UI UI `yaml:"ui,omitempty"` + Keys map[string][]string `yaml:"keys,omitempty"` + Theme Theme `yaml:"theme,omitempty"` +} + +// UI holds settings that are not connection-related. +type UI struct { + Appearance string `yaml:"appearance,omitempty"` // auto | dark | light + ConfirmDestructive *bool `yaml:"confirm_destructive,omitempty"` + TTLWarnBelow *time.Duration `yaml:"ttl_warn_below,omitempty"` + MaskValues *bool `yaml:"mask_values,omitempty"` + ClipboardClear *time.Duration `yaml:"clipboard_clear_after,omitempty"` + CacheTTL *time.Duration `yaml:"cache_ttl,omitempty"` + RequireCAS *bool `yaml:"require_cas,omitempty"` + ReadOnly *bool `yaml:"read_only,omitempty"` + BrowserCommand string `yaml:"browser_command,omitempty"` +} + +// Profile is one named Vault connection + auth configuration. The +// zero-valued Profile is a legal "say nothing" value; every optional scalar +// is a pointer so that "false" and "unset" are distinguishable (this is +// what makes the defaults/profile merge in resolve.go correct). +type Profile struct { + Address string `yaml:"address,omitempty"` + Namespace string `yaml:"namespace,omitempty"` + Production *bool `yaml:"production,omitempty"` + ReadOnly *bool `yaml:"read_only,omitempty"` + IgnoreEnv *bool `yaml:"ignore_env,omitempty"` + TLS TLS `yaml:"tls,omitempty"` + Client ClientOpts `yaml:"client,omitempty"` + Auth Auth `yaml:"auth,omitempty"` + Token TokenOpts `yaml:"token,omitempty"` + Favourites []string `yaml:"favourites,omitempty"` +} + +type TLS struct { + CACert string `yaml:"ca_cert,omitempty"` + CAPath string `yaml:"ca_path,omitempty"` + ClientCert string `yaml:"client_cert,omitempty"` + ClientKey string `yaml:"client_key,omitempty"` + ServerName string `yaml:"tls_server_name,omitempty"` + SkipVerify *bool `yaml:"skip_verify,omitempty"` +} + +type ClientOpts struct { + Timeout *time.Duration `yaml:"timeout,omitempty"` + MaxRetries *int `yaml:"max_retries,omitempty"` + MinRetryWait *time.Duration `yaml:"min_retry_wait,omitempty"` + MaxRetryWait *time.Duration `yaml:"max_retry_wait,omitempty"` + SRVLookup *bool `yaml:"srv_lookup,omitempty"` + DisableRedirects *bool `yaml:"disable_redirects,omitempty"` + HTTPProxy string `yaml:"http_proxy,omitempty"` + RateLimit string `yaml:"rate_limit,omitempty"` + Headers map[string]string `yaml:"headers,omitempty"` +} + +// Auth configures which auth method a profile logs in with, plus prefilled +// parameters. Params values are looked up by internal/auth.Field.Name (or +// Field.ConfigKey when set) and never store secrets that have an +// EnvFallback equivalent (see internal/auth/method.go). +type Auth struct { + Method string `yaml:"method,omitempty"` + Mount string `yaml:"mount,omitempty"` + Params map[string]string `yaml:"params,omitempty"` + OIDC OIDCOpts `yaml:"oidc,omitempty"` +} + +type OIDCOpts struct { + ListenAddress string `yaml:"listen_address,omitempty"` + Port *int `yaml:"port,omitempty"` + CallbackMethod string `yaml:"callback_method,omitempty"` + CallbackHost string `yaml:"callback_host,omitempty"` + CallbackPort *int `yaml:"callback_port,omitempty"` + CallbackPath string `yaml:"callback_path,omitempty"` + SkipBrowser *bool `yaml:"skip_browser,omitempty"` + AbortOnBrowserError *bool `yaml:"abort_on_browser_error,omitempty"` + Timeout *time.Duration `yaml:"timeout,omitempty"` +} + +// TokenOpts controls how the resolved token is persisted between runs. +type TokenOpts struct { + // Storage is "vault-cli" (default, shares ~/.vault-token / the + // configured token helper with the real Vault CLI), "profile" (its own + // file under the XDG state dir, keyed by profile name), or "none" + // (never persisted). + Storage string `yaml:"storage,omitempty"` + File string `yaml:"file,omitempty"` + Value string `yaml:"value,omitempty"` // discouraged; Load warns + MirrorToVaultCLI *bool `yaml:"mirror_to_vault_cli,omitempty"` + AutoRenew *bool `yaml:"auto_renew,omitempty"` + RenewIncrement *time.Duration `yaml:"renew_increment,omitempty"` + RevokeOnLogout *bool `yaml:"revoke_on_logout,omitempty"` + ValidateOnStartup *bool `yaml:"validate_on_startup,omitempty"` +} + +// Theme is the YAML-facing color/appearance schema; see internal/ui/theme +// for the Go types actually consumed by rendering. +type Theme struct { + BorderStyle string `yaml:"border_style,omitempty"` + MaskChar string `yaml:"mask_char,omitempty"` + Colors map[string]Color `yaml:"colors,omitempty"` +} + +// Color is either a single hex value (same in light and dark) or an +// adaptive pair. UnmarshalYAML (color.go) accepts both forms. +type Color struct { + Light string + Dark string +} diff --git a/internal/config/env.go b/internal/config/env.go new file mode 100644 index 0000000..3c4cbde --- /dev/null +++ b/internal/config/env.go @@ -0,0 +1,86 @@ +package config + +import ( + "os" + "strconv" + "time" +) + +// Getenv abstracts os.Getenv so tests can inject a fake environment. +type Getenv func(string) (string, bool) + +// OSEnviron is the default Getenv backed by the real process environment. +func OSEnviron(key string) (string, bool) { return os.LookupEnv(key) } + +// Env is every environment variable vault-tui recognises. Vault-native +// names keep their VAULT_ prefix for CLI parity; vault-tui-only settings use +// VAULT_TUI_. Kept as named constants (rather than inlined strings in +// resolve.go) so the set is easy to audit and document in one place. +const ( + EnvAddress = "VAULT_ADDR" + EnvNamespace = "VAULT_NAMESPACE" + EnvCACert = "VAULT_CACERT" + EnvCACertBytes = "VAULT_CACERT_BYTES" + EnvCAPath = "VAULT_CAPATH" + EnvClientCert = "VAULT_CLIENT_CERT" + EnvClientKey = "VAULT_CLIENT_KEY" + EnvTLSServerName = "VAULT_TLS_SERVER_NAME" + EnvSkipVerify = "VAULT_SKIP_VERIFY" + EnvClientTimeout = "VAULT_CLIENT_TIMEOUT" + EnvMaxRetries = "VAULT_MAX_RETRIES" + EnvSRVLookup = "VAULT_SRV_LOOKUP" + EnvDisableRedirects = "VAULT_DISABLE_REDIRECTS" + EnvHTTPProxy = "VAULT_HTTP_PROXY" + EnvProxyAddr = "VAULT_PROXY_ADDR" + EnvRateLimit = "VAULT_RATE_LIMIT" + EnvToken = "VAULT_TOKEN" + EnvConfigPathVaultCLI = "VAULT_CONFIG_PATH" // consumed by api/cliconfig, not by us directly + + EnvTUIConfig = "VAULT_TUI_CONFIG" + EnvTUIProfile = "VAULT_TUI_PROFILE" + EnvTUINoEnv = "VAULT_TUI_NO_ENV" + EnvTUIReadOnly = "VAULT_TUI_READ_ONLY" + + // EnvAuthParamPrefix + strings.ToUpper(Field.Name) is consulted by + // internal/auth.Prefill alongside each Field's own EnvFallback list. + EnvAuthParamPrefix = "VAULT_TUI_AUTH_" +) + +func envBool(get Getenv, key string) (bool, bool) { + v, ok := get(key) + if !ok || v == "" { + return false, false + } + b, err := strconv.ParseBool(v) + if err != nil { + return false, false + } + return b, true +} + +func envDuration(get Getenv, key string) (time.Duration, bool) { + v, ok := get(key) + if !ok || v == "" { + return 0, false + } + d, err := time.ParseDuration(v) + if err != nil { + if secs, serr := strconv.Atoi(v); serr == nil { + return time.Duration(secs) * time.Second, true + } + return 0, false + } + return d, true +} + +func envInt(get Getenv, key string) (int, bool) { + v, ok := get(key) + if !ok || v == "" { + return 0, false + } + n, err := strconv.Atoi(v) + if err != nil { + return 0, false + } + return n, true +} diff --git a/internal/config/load.go b/internal/config/load.go new file mode 100644 index 0000000..24d414a --- /dev/null +++ b/internal/config/load.go @@ -0,0 +1,78 @@ +package config + +import ( + "bytes" + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +// Load reads and decodes the file at path. A missing file is not an error: +// it returns a zero-valued *File so the tool remains usable purely from +// flags/env (see the package doc). Unknown YAML keys are a hard error so +// typos in a hand-edited config surface immediately instead of being +// silently ignored. +func Load(path string) (*File, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return &File{Version: SchemaVersion}, nil + } + return nil, fmt.Errorf("reading config %q: %w", path, err) + } + + var f File + dec := yaml.NewDecoder(bytes.NewReader(data)) + dec.KnownFields(true) + if err := dec.Decode(&f); err != nil { + return nil, fmt.Errorf("parsing config %q: %w", path, err) + } + + if f.Version == 0 { + f.Version = SchemaVersion + } + if f.Version != SchemaVersion { + return nil, fmt.Errorf("config %q has version %d, vault-tui supports version %d", path, f.Version, SchemaVersion) + } + for name, p := range f.Profiles { + if p != nil && p.Token.Value != "" { + fmt.Fprintf(os.Stderr, "warning: profile %q sets token.value directly in the config file; prefer VAULT_TOKEN or token.file\n", name) + } + } + return &f, nil +} + +// Save writes f to path as YAML, creating parent directories as needed. +// The file is written with 0600 permissions since profiles may carry +// TLS key paths and (discouraged but supported) inline token values. +func Save(path string, f *File) error { + if err := os.MkdirAll(dirOf(path), 0o700); err != nil { + return fmt.Errorf("creating config directory: %w", err) + } + var buf bytes.Buffer + enc := yaml.NewEncoder(&buf) + enc.SetIndent(2) + if err := enc.Encode(f); err != nil { + return fmt.Errorf("encoding config: %w", err) + } + if err := enc.Close(); err != nil { + return err + } + return os.WriteFile(path, buf.Bytes(), 0o600) +} + +func dirOf(path string) string { + for i := len(path) - 1; i >= 0; i-- { + if path[i] == '/' { + return path[:i] + } + } + return "." +} + +// Exists reports whether a config file is present at path. +func Exists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/internal/config/paths.go b/internal/config/paths.go new file mode 100644 index 0000000..9b17bc6 --- /dev/null +++ b/internal/config/paths.go @@ -0,0 +1,52 @@ +package config + +import ( + "os" + "path/filepath" +) + +const ( + appDirName = "vault-tui" + fileName = "config.yaml" + configEnv = "VAULT_TUI_CONFIG" + profileEnv = "VAULT_TUI_PROFILE" + noEnvEnv = "VAULT_TUI_NO_ENV" + readOnlyEnv = "VAULT_TUI_READ_ONLY" +) + +// DefaultPath returns the config file location used when neither --config +// nor VAULT_TUI_CONFIG is set: $XDG_CONFIG_HOME/vault-tui/config.yaml, +// falling back to ~/.config/vault-tui/config.yaml. +func DefaultPath() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, appDirName, fileName), nil +} + +// ResolvePath applies the discovery order: --config flag > VAULT_TUI_CONFIG +// env > default XDG location. +func ResolvePath(flag string) (string, error) { + if flag != "" { + return flag, nil + } + if v := os.Getenv(configEnv); v != "" { + return v, nil + } + return DefaultPath() +} + +// StateDir returns the directory vault-tui uses for its own per-profile +// token cache (internal/token/store_profile.go): $XDG_STATE_HOME/vault-tui, +// falling back to ~/.local/state/vault-tui. +func StateDir() (string, error) { + if v := os.Getenv("XDG_STATE_HOME"); v != "" { + return filepath.Join(v, appDirName), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".local", "state", appDirName), nil +} diff --git a/internal/config/resolve.go b/internal/config/resolve.go new file mode 100644 index 0000000..5e47d54 --- /dev/null +++ b/internal/config/resolve.go @@ -0,0 +1,414 @@ +package config + +import ( + "fmt" + "time" +) + +// Overrides is the "flag" layer: whatever the user passed on the command +// line. Every field is the zero value when not set; string "" and bool +// pointer nil both mean "the flag layer says nothing about this field", +// which is why the bool fields here are pointers (a flag can meaningfully +// set skip-verify back to false) while the plainly additive string fields +// are not. +type Overrides struct { + Profile string + Address string + Namespace string + Token string + + CACert string + ClientCert string + ClientKey string + SkipVerify *bool + + ReadOnly *bool + NoEnv bool // --no-env: disable the env layer entirely for this run + NoColor bool // --no-color: suppress ANSI color in the TUI +} + +// layer is one candidate value plus where it would come from if chosen. +type layer[T any] struct { + val *T + layer string + key string +} + +// pick returns the value (written into *dst) and Origin of the first layer +// (in argument order, highest precedence first) whose val is non-nil. +// Layers whose val is nil are "silent" and skipped. If no layer matches, +// dst is left untouched and a zero Origin is returned. +func pick[T any](dst *T, layers ...layer[T]) Origin { + for _, l := range layers { + if l.val != nil { + *dst = *l.val + return Origin{Layer: l.layer, Key: l.key} + } + } + return Origin{} +} + +func strLayer(v, layerName, key string) layer[string] { + if v == "" { + return layer[string]{} + } + return layer[string]{val: &v, layer: layerName, key: key} +} + +// Resolve builds a Settings for profileName out of f (the loaded config +// file), ov (the flag layer), and the process environment (or an injected +// Getenv in tests). Precedence, for every field independently: +// +// flag > env > profile > defaults block > builtin default +// +// This function — not api.DefaultConfig()'s own ReadEnvironment — is the +// single source of truth for that ordering. api.NewClient applies +// VAULT_TOKEN/VAULT_NAMESPACE itself as soon as a *api.Config is handed to +// it, which would silently place env above every other layer; internal/vault.New +// undoes that (ClearToken + SetNamespace) immediately after construction so +// only values that went through this function ever take effect. +func Resolve(f *File, profileName string, ov Overrides, get Getenv) (*Settings, error) { + if get == nil { + get = OSEnviron + } + name, err := SelectProfile(f, profileName, get) + if err != nil { + return nil, err + } + prof := f.Profiles[name] + if prof == nil { + prof = &Profile{} + } + def := f.Defaults + + noEnv := ov.NoEnv + if !noEnv { + if b, ok := envBool(get, EnvTUINoEnv); ok { + noEnv = b + } + } + if prof.IgnoreEnv != nil && *prof.IgnoreEnv { + noEnv = true + } + + envGet := get + if noEnv { + envGet = func(string) (string, bool) { return "", false } + } + + s := &Settings{Profile: name, Origins: map[string]Origin{}} + set := func(field string, o Origin) { + if o.Layer != "" { + s.Origins[field] = o + } + } + + set("address", pick(&s.Address, + strLayer(ov.Address, LayerFlag, "--address"), + envStrLayer(envGet, EnvAddress), + strLayer(prof.Address, LayerProfile, "profiles."+name+".address"), + strLayer(def.Address, LayerDefaults, "defaults.address"), + layer[string]{val: strp("https://127.0.0.1:8200"), layer: LayerBuiltin, key: "builtin"}, + )) + set("namespace", pick(&s.Namespace, + strLayer(ov.Namespace, LayerFlag, "--namespace"), + envStrLayer(envGet, EnvNamespace), + strLayer(prof.Namespace, LayerProfile, "profiles."+name+".namespace"), + strLayer(def.Namespace, LayerDefaults, "defaults.namespace"), + )) + + set("ca_cert", pick(&s.CACert, + strLayer(ov.CACert, LayerFlag, "--ca-cert"), + envStrLayer(envGet, EnvCACert), + strLayer(prof.TLS.CACert, LayerProfile, "profiles."+name+".tls.ca_cert"), + strLayer(def.TLS.CACert, LayerDefaults, "defaults.tls.ca_cert"), + )) + if v, ok := envGet(EnvCACertBytes); ok && v != "" { + s.CACertPEM = []byte(v) + } + set("ca_path", pick(&s.CAPath, + envStrLayer(envGet, EnvCAPath), + strLayer(prof.TLS.CAPath, LayerProfile, "profiles."+name+".tls.ca_path"), + strLayer(def.TLS.CAPath, LayerDefaults, "defaults.tls.ca_path"), + )) + set("client_cert", pick(&s.ClientCert, + strLayer(ov.ClientCert, LayerFlag, "--client-cert"), + envStrLayer(envGet, EnvClientCert), + strLayer(prof.TLS.ClientCert, LayerProfile, "profiles."+name+".tls.client_cert"), + strLayer(def.TLS.ClientCert, LayerDefaults, "defaults.tls.client_cert"), + )) + set("client_key", pick(&s.ClientKey, + strLayer(ov.ClientKey, LayerFlag, "--client-key"), + envStrLayer(envGet, EnvClientKey), + strLayer(prof.TLS.ClientKey, LayerProfile, "profiles."+name+".tls.client_key"), + strLayer(def.TLS.ClientKey, LayerDefaults, "defaults.tls.client_key"), + )) + set("tls_server_name", pick(&s.ServerName, + envStrLayer(envGet, EnvTLSServerName), + strLayer(prof.TLS.ServerName, LayerProfile, "profiles."+name+".tls.tls_server_name"), + strLayer(def.TLS.ServerName, LayerDefaults, "defaults.tls.tls_server_name"), + )) + set("skip_verify", pick(&s.SkipVerify, + boolLayer(ov.SkipVerify, LayerFlag, "--tls-skip-verify"), + envBoolLayer(envGet, EnvSkipVerify), + boolLayer(prof.TLS.SkipVerify, LayerProfile, "profiles."+name+".tls.skip_verify"), + boolLayer(def.TLS.SkipVerify, LayerDefaults, "defaults.tls.skip_verify"), + )) + + set("timeout", pick(&s.Timeout, + envDurationLayer(envGet, EnvClientTimeout), + durationLayer(prof.Client.Timeout, LayerProfile, "profiles."+name+".client.timeout"), + durationLayer(def.Client.Timeout, LayerDefaults, "defaults.client.timeout"), + durationLayer(durp(60*time.Second), LayerBuiltin, "builtin"), + )) + set("max_retries", pick(&s.MaxRetries, + envIntLayer(envGet, EnvMaxRetries), + intLayer(prof.Client.MaxRetries, LayerProfile, "profiles."+name+".client.max_retries"), + intLayer(def.Client.MaxRetries, LayerDefaults, "defaults.client.max_retries"), + intLayer(intp(2), LayerBuiltin, "builtin"), + )) + set("min_retry_wait", pick(&s.MinRetryWait, + durationLayer(prof.Client.MinRetryWait, LayerProfile, "profiles."+name+".client.min_retry_wait"), + durationLayer(def.Client.MinRetryWait, LayerDefaults, "defaults.client.min_retry_wait"), + )) + set("max_retry_wait", pick(&s.MaxRetryWait, + durationLayer(prof.Client.MaxRetryWait, LayerProfile, "profiles."+name+".client.max_retry_wait"), + durationLayer(def.Client.MaxRetryWait, LayerDefaults, "defaults.client.max_retry_wait"), + )) + set("srv_lookup", pick(&s.SRVLookup, + envBoolLayer(envGet, EnvSRVLookup), + boolLayer(prof.Client.SRVLookup, LayerProfile, "profiles."+name+".client.srv_lookup"), + boolLayer(def.Client.SRVLookup, LayerDefaults, "defaults.client.srv_lookup"), + )) + set("disable_redirects", pick(&s.DisableRedirects, + envBoolLayer(envGet, EnvDisableRedirects), + boolLayer(prof.Client.DisableRedirects, LayerProfile, "profiles."+name+".client.disable_redirects"), + boolLayer(def.Client.DisableRedirects, LayerDefaults, "defaults.client.disable_redirects"), + )) + set("http_proxy", pick(&s.HTTPProxy, + envStrLayer(envGet, EnvHTTPProxy), + envStrLayer(envGet, EnvProxyAddr), + strLayer(prof.Client.HTTPProxy, LayerProfile, "profiles."+name+".client.http_proxy"), + strLayer(def.Client.HTTPProxy, LayerDefaults, "defaults.client.http_proxy"), + )) + set("rate_limit", pick(&s.RateLimit, + envStrLayer(envGet, EnvRateLimit), + strLayer(prof.Client.RateLimit, LayerProfile, "profiles."+name+".client.rate_limit"), + strLayer(def.Client.RateLimit, LayerDefaults, "defaults.client.rate_limit"), + )) + s.Headers = mergeHeaders(def.Client.Headers, prof.Client.Headers) + + s.Auth = mergeAuth(def.Auth, prof.Auth) + s.Token = mergeTokenOpts(def.Token, prof.Token) + + set("read_only", pick(&s.ReadOnly, + boolLayer(ov.ReadOnly, LayerFlag, "--read-only"), + envBoolLayer(get, EnvTUIReadOnly), // the read-only guard is NOT subject to --no-env + boolLayer(prof.ReadOnly, LayerProfile, "profiles."+name+".read_only"), + boolLayer(def.ReadOnly, LayerDefaults, "defaults.read_only"), + boolLayer(f.UI.ReadOnly, LayerDefaults, "ui.read_only"), + boolLayer(boolp(true), LayerBuiltin, "builtin"), // safe default: read-only until asked otherwise + )) + + set("mask_values", pick(&s.MaskValues, boolLayer(f.UI.MaskValues, LayerDefaults, "ui.mask_values"), boolLayer(boolp(true), LayerBuiltin, "builtin"))) + set("confirm_destructive", pick(&s.ConfirmDestructive, boolLayer(f.UI.ConfirmDestructive, LayerDefaults, "ui.confirm_destructive"), boolLayer(boolp(true), LayerBuiltin, "builtin"))) + set("require_cas", pick(&s.RequireCAS, boolLayer(f.UI.RequireCAS, LayerDefaults, "ui.require_cas"), boolLayer(boolp(true), LayerBuiltin, "builtin"))) + set("cache_ttl", pick(&s.CacheTTL, durationLayer(f.UI.CacheTTL, LayerDefaults, "ui.cache_ttl"), durationLayer(durp(60*time.Second), LayerBuiltin, "builtin"))) + set("ttl_warn_below", pick(&s.TTLWarnBelow, durationLayer(f.UI.TTLWarnBelow, LayerDefaults, "ui.ttl_warn_below"), durationLayer(durp(5*time.Minute), LayerBuiltin, "builtin"))) + set("clipboard_clear_after", pick(&s.ClipboardClear, durationLayer(f.UI.ClipboardClear, LayerDefaults, "ui.clipboard_clear_after"))) + set("appearance", pick(&s.Appearance, strLayer(f.UI.Appearance, LayerDefaults, "ui.appearance"), strLayer("auto", LayerBuiltin, "builtin"))) + s.BrowserCommand = f.UI.BrowserCommand + + // NO_COLOR (https://no-color.org) is a terminal-wide convention, not a + // Vault setting, so — like read_only above — it's read from the raw + // environment even under --no-env. + s.NoColor = ov.NoColor + if !s.NoColor { + if _, isSet := get("NO_COLOR"); isSet { + s.NoColor = true + } + } + + return s, nil +} + +// SelectProfile applies the profile-selection precedence: --profile flag > +// VAULT_TUI_PROFILE env > current_profile in the file > the sole profile if +// exactly one exists > "default". +func SelectProfile(f *File, flag string, get Getenv) (string, error) { + if flag != "" { + return flag, nil + } + if get == nil { + get = OSEnviron + } + if v, ok := get(EnvTUIProfile); ok && v != "" { + return v, nil + } + if f.CurrentProfile != "" { + return f.CurrentProfile, nil + } + if len(f.Profiles) == 1 { + for name := range f.Profiles { + return name, nil + } + } + if len(f.Profiles) == 0 { + return "default", nil + } + return "", fmt.Errorf("multiple profiles configured and none selected: pass --profile, set %s, or set current_profile in the config file", EnvTUIProfile) +} + +func mergeHeaders(a, b map[string]string) map[string]string { + out := map[string]string{} + for k, v := range a { + out[k] = v + } + for k, v := range b { + out[k] = v + } + return out +} + +func mergeAuth(def, prof Auth) Auth { + out := def + if prof.Method != "" { + out.Method = prof.Method + } + if prof.Mount != "" { + out.Mount = prof.Mount + } + if len(prof.Params) > 0 { + merged := map[string]string{} + for k, v := range def.Params { + merged[k] = v + } + for k, v := range prof.Params { + merged[k] = v + } + out.Params = merged + } + out.OIDC = mergeOIDCOpts(def.OIDC, prof.OIDC) + return out +} + +func mergeOIDCOpts(def, prof OIDCOpts) OIDCOpts { + out := def + if prof.ListenAddress != "" { + out.ListenAddress = prof.ListenAddress + } + if prof.Port != nil { + out.Port = prof.Port + } + if prof.CallbackMethod != "" { + out.CallbackMethod = prof.CallbackMethod + } + if prof.CallbackHost != "" { + out.CallbackHost = prof.CallbackHost + } + if prof.CallbackPort != nil { + out.CallbackPort = prof.CallbackPort + } + if prof.CallbackPath != "" { + out.CallbackPath = prof.CallbackPath + } + if prof.SkipBrowser != nil { + out.SkipBrowser = prof.SkipBrowser + } + if prof.AbortOnBrowserError != nil { + out.AbortOnBrowserError = prof.AbortOnBrowserError + } + if prof.Timeout != nil { + out.Timeout = prof.Timeout + } + return out +} + +func mergeTokenOpts(def, prof TokenOpts) TokenOpts { + out := def + if prof.Storage != "" { + out.Storage = prof.Storage + } + if prof.File != "" { + out.File = prof.File + } + if prof.Value != "" { + out.Value = prof.Value + } + if prof.MirrorToVaultCLI != nil { + out.MirrorToVaultCLI = prof.MirrorToVaultCLI + } + if prof.AutoRenew != nil { + out.AutoRenew = prof.AutoRenew + } + if prof.RenewIncrement != nil { + out.RenewIncrement = prof.RenewIncrement + } + if prof.RevokeOnLogout != nil { + out.RevokeOnLogout = prof.RevokeOnLogout + } + if prof.ValidateOnStartup != nil { + out.ValidateOnStartup = prof.ValidateOnStartup + } + return out +} + +// --- small helpers ----------------------------------------------------- + +func envStrLayer(get Getenv, key string) layer[string] { + v, ok := get(key) + if !ok || v == "" { + return layer[string]{} + } + return layer[string]{val: &v, layer: LayerEnv, key: key} +} + +func envBoolLayer(get Getenv, key string) layer[bool] { + b, ok := envBool(get, key) + if !ok { + return layer[bool]{} + } + return layer[bool]{val: &b, layer: LayerEnv, key: key} +} + +func envDurationLayer(get Getenv, key string) layer[time.Duration] { + d, ok := envDuration(get, key) + if !ok { + return layer[time.Duration]{} + } + return layer[time.Duration]{val: &d, layer: LayerEnv, key: key} +} + +func envIntLayer(get Getenv, key string) layer[int] { + n, ok := envInt(get, key) + if !ok { + return layer[int]{} + } + return layer[int]{val: &n, layer: LayerEnv, key: key} +} + +func boolLayer(v *bool, layerName, key string) layer[bool] { + if v == nil { + return layer[bool]{} + } + return layer[bool]{val: v, layer: layerName, key: key} +} + +func durationLayer(v *time.Duration, layerName, key string) layer[time.Duration] { + if v == nil { + return layer[time.Duration]{} + } + return layer[time.Duration]{val: v, layer: layerName, key: key} +} + +func intLayer(v *int, layerName, key string) layer[int] { + if v == nil { + return layer[int]{} + } + return layer[int]{val: v, layer: layerName, key: key} +} + +func strp(s string) *string { return &s } +func boolp(b bool) *bool { return &b } +func intp(i int) *int { return &i } +func durp(d time.Duration) *time.Duration { return &d } diff --git a/internal/config/resolve_test.go b/internal/config/resolve_test.go new file mode 100644 index 0000000..6d80d00 --- /dev/null +++ b/internal/config/resolve_test.go @@ -0,0 +1,147 @@ +package config + +import ( + "testing" +) + +func envFrom(m map[string]string) Getenv { + return func(k string) (string, bool) { + v, ok := m[k] + return v, ok + } +} + +func TestResolvePrecedence_Address(t *testing.T) { + f := &File{ + Version: SchemaVersion, + CurrentProfile: "p1", + Defaults: Profile{Address: "https://defaults.example.com"}, + Profiles: map[string]*Profile{ + "p1": {Address: "https://profile.example.com"}, + }, + } + + cases := []struct { + name string + ov Overrides + env map[string]string + want string + }{ + {"builtin only", Overrides{}, nil, "https://profile.example.com"}, // profile beats defaults + {"env beats profile", Overrides{}, map[string]string{EnvAddress: "https://env.example.com"}, "https://env.example.com"}, + {"flag beats env", Overrides{Address: "https://flag.example.com"}, map[string]string{EnvAddress: "https://env.example.com"}, "https://flag.example.com"}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + s, err := Resolve(f, "", c.ov, envFrom(c.env)) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if s.Address != c.want { + t.Errorf("Address = %q, want %q", s.Address, c.want) + } + }) + } +} + +func TestResolve_DefaultsOnlyWhenNoProfile(t *testing.T) { + f := &File{Version: SchemaVersion, Defaults: Profile{Address: "https://defaults.example.com"}} + s, err := Resolve(f, "", Overrides{}, envFrom(nil)) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if s.Address != "https://defaults.example.com" { + t.Errorf("Address = %q, want defaults value", s.Address) + } +} + +func TestResolve_BuiltinFallback(t *testing.T) { + f := &File{Version: SchemaVersion} + s, err := Resolve(f, "", Overrides{}, envFrom(nil)) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if s.Address != "https://127.0.0.1:8200" { + t.Errorf("Address = %q, want builtin default", s.Address) + } + if s.OriginOf("address").Layer != LayerBuiltin { + t.Errorf("origin layer = %q, want %q", s.OriginOf("address").Layer, LayerBuiltin) + } +} + +func TestResolve_IgnoreEnvPerProfile(t *testing.T) { + ignore := true + f := &File{ + Version: SchemaVersion, + Profiles: map[string]*Profile{ + "staging": {Address: "https://staging.example.com", IgnoreEnv: &ignore}, + }, + } + s, err := Resolve(f, "staging", Overrides{}, envFrom(map[string]string{EnvAddress: "https://env.example.com"})) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if s.Address != "https://staging.example.com" { + t.Errorf("Address = %q, want profile value (env should be ignored)", s.Address) + } +} + +func TestResolve_NoEnvOverrideFlag(t *testing.T) { + f := &File{ + Version: SchemaVersion, + Profiles: map[string]*Profile{ + "p": {Address: "https://profile.example.com"}, + }, + } + s, err := Resolve(f, "p", Overrides{NoEnv: true}, envFrom(map[string]string{EnvAddress: "https://env.example.com"})) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if s.Address != "https://profile.example.com" { + t.Errorf("Address = %q, want profile value (--no-env should suppress VAULT_ADDR)", s.Address) + } +} + +func TestSelectProfile(t *testing.T) { + multi := &File{Profiles: map[string]*Profile{"a": {}, "b": {}}} + if _, err := SelectProfile(multi, "", envFrom(nil)); err == nil { + t.Error("expected error selecting among multiple profiles with no selection") + } + if name, err := SelectProfile(multi, "a", envFrom(nil)); err != nil || name != "a" { + t.Errorf("flag selection: got (%q, %v)", name, err) + } + if name, err := SelectProfile(multi, "", envFrom(map[string]string{EnvTUIProfile: "b"})); err != nil || name != "b" { + t.Errorf("env selection: got (%q, %v)", name, err) + } + + single := &File{Profiles: map[string]*Profile{"only": {}}} + if name, err := SelectProfile(single, "", envFrom(nil)); err != nil || name != "only" { + t.Errorf("sole profile selection: got (%q, %v)", name, err) + } + + empty := &File{} + if name, err := SelectProfile(empty, "", envFrom(nil)); err != nil || name != "default" { + t.Errorf("empty profiles: got (%q, %v)", name, err) + } +} + +func TestResolve_ReadOnlyDefaultsSafe(t *testing.T) { + f := &File{Version: SchemaVersion} + s, err := Resolve(f, "", Overrides{}, envFrom(nil)) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if !s.ReadOnly { + t.Error("ReadOnly should default to true when nothing overrides it") + } + + no := false + s, err = Resolve(f, "", Overrides{ReadOnly: &no}, envFrom(nil)) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if s.ReadOnly { + t.Error("--write (ReadOnly=false override) should have taken effect") + } +} diff --git a/internal/config/settings.go b/internal/config/settings.go new file mode 100644 index 0000000..b1f8328 --- /dev/null +++ b/internal/config/settings.go @@ -0,0 +1,77 @@ +package config + +import "time" + +// Layer names used in Origin, most-significant first. Exported as +// constants so callers can compare without typos. +const ( + LayerFlag = "flag" + LayerEnv = "env" + LayerProfile = "profile" + LayerDefaults = "defaults" + LayerBuiltin = "builtin" +) + +// Origin records which layer produced a resolved value and the key that +// carried it, so the UI/CLI can explain itself (e.g. status bar: "address: +// VAULT_ADDR"). +type Origin struct { + Layer string + Key string +} + +// Settings is the fully flattened, precedence-resolved connection +// configuration for one profile. internal/vault.New builds an *api.Client +// from exactly this — it never re-reads env or config itself, which is what +// makes the flag>env>profile>defaults>builtin precedence in resolve.go the +// single source of truth (see resolve.go's package doc for why +// api.DefaultConfig()'s own env-reading must not be relied upon instead). +type Settings struct { + Profile string + Address string + Namespace string + + CACert string + CAPath string + CACertPEM []byte + ClientCert string + ClientKey string + ServerName string + SkipVerify bool + + Timeout time.Duration + MaxRetries int + MinRetryWait time.Duration + MaxRetryWait time.Duration + SRVLookup bool + DisableRedirects bool + HTTPProxy string + RateLimit string + Headers map[string]string + + Auth Auth + Token TokenOpts + + ReadOnly bool + MaskValues bool + ConfirmDestructive bool + RequireCAS bool + CacheTTL time.Duration + TTLWarnBelow time.Duration + ClipboardClear time.Duration + BrowserCommand string + Appearance string + NoColor bool + + Origins map[string]Origin +} + +// Fmt returns a short "value (source)" string for display, e.g. +// "https://vault.example.com (VAULT_ADDR)". Falls back to just the key name +// used by that layer when no origin was recorded for field. +func (s *Settings) OriginOf(field string) Origin { + if s.Origins == nil { + return Origin{} + } + return s.Origins[field] +} diff --git a/internal/token/lifecycle.go b/internal/token/lifecycle.go new file mode 100644 index 0000000..a2fcb90 --- /dev/null +++ b/internal/token/lifecycle.go @@ -0,0 +1,110 @@ +package token + +import ( + "context" + "fmt" + "time" + + "github.com/hashicorp/vault/api" +) + +// RenewEvent is emitted by AutoRenewer as the watched token's lifecycle +// changes. Err set + Terminal true means the watcher has given up and the +// token will eventually expire without further notice. +type RenewEvent struct { + At time.Time + TTL time.Duration + Err error + Terminal bool +} + +// AutoRenewer wraps api.LifetimeWatcher with the two special cases that +// matter for a token we did not necessarily just mint ourselves: batch +// tokens are not renewable, and root tokens have TTL == 0 (never expire). +// NewAutoRenewer returns (nil, nil) for either case — callers should treat +// a nil renewer as "nothing to do", not an error. +type AutoRenewer struct { + client *api.Client + w *api.LifetimeWatcher + events chan RenewEvent +} + +// NewAutoRenewer builds a renewer from a *Info (e.g. from Lookup) rather +// than requiring the original login *api.Secret, since a token resolved +// from ~/.vault-token has no such Secret — this synthesises an equivalent +// one from the lookup data. +func NewAutoRenewer(c *api.Client, info *Info, increment time.Duration) (*AutoRenewer, error) { + if !info.Renewable || info.ExpireTime == nil { + return nil, nil + } + sec := &api.Secret{ + Auth: &api.SecretAuth{ + ClientToken: c.Token(), + LeaseDuration: int(info.TTL.Seconds()), + Renewable: info.Renewable, + }, + } + w, err := c.NewLifetimeWatcher(&api.LifetimeWatcherInput{ + Secret: sec, + Increment: int(increment.Seconds()), + RenewBuffer: api.DefaultLifetimeWatcherRenewBuffer, + RenewBehavior: api.RenewBehaviorIgnoreErrors, + }) + if err != nil { + return nil, fmt.Errorf("creating lifetime watcher: %w", err) + } + return &AutoRenewer{client: c, w: w, events: make(chan RenewEvent, 8)}, nil +} + +// Start runs the watcher until ctx is cancelled or Stop is called. +func (a *AutoRenewer) Start(ctx context.Context) { + go a.w.Start() + go func() { + defer close(a.events) + for { + select { + case <-ctx.Done(): + a.w.Stop() + return + case out, ok := <-a.w.RenewCh(): + if !ok { + return + } + a.emit(RenewEvent{At: time.Now(), TTL: leaseDuration(out)}) + case err, ok := <-a.w.DoneCh(): + if !ok { + return + } + a.emit(RenewEvent{At: time.Now(), Err: err, Terminal: true}) + return + } + } + }() +} + +func (a *AutoRenewer) emit(e RenewEvent) { + select { + case a.events <- e: + default: + } +} + +// Events returns the channel of renewal notifications; closed when the +// watcher stops. +func (a *AutoRenewer) Events() <-chan RenewEvent { return a.events } + +// Stop releases the underlying watcher. +func (a *AutoRenewer) Stop() { a.w.Stop() } + +// leaseDuration reads the renewed TTL out of a RenewOutput. Token renewals +// (our only use case) carry it on Secret.Auth, not the top-level +// Secret.LeaseDuration field that non-auth lease renewals use. +func leaseDuration(out *api.RenewOutput) time.Duration { + if out == nil || out.Secret == nil { + return 0 + } + if out.Secret.Auth != nil { + return time.Duration(out.Secret.Auth.LeaseDuration) * time.Second + } + return time.Duration(out.Secret.LeaseDuration) * time.Second +} diff --git a/internal/token/lookup.go b/internal/token/lookup.go new file mode 100644 index 0000000..4b3d81e --- /dev/null +++ b/internal/token/lookup.go @@ -0,0 +1,141 @@ +package token + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/hashicorp/vault/api" + + "git.morlana.online/f.weber/vault-tui/internal/vault" +) + +// ErrTokenInvalid means the token was resolved from storage but Vault +// rejects it (expired, revoked, or simply wrong) — distinct from a +// transport failure, so callers can route straight back to the auth +// screen instead of showing a generic error. +var ErrTokenInvalid = errors.New("token is invalid or expired") + +// Info is the normalised result of auth/token/lookup-self. +type Info struct { + Accessor string + DisplayName string + EntityID string + Policies []string + IdentityPolicies []string + Type string // "service" | "batch" + Path string + NamespacePath string + Meta map[string]string + + Renewable bool + Orphan bool + NumUses int + TTL time.Duration + CreationTTL time.Duration + IssueTime time.Time + ExpireTime *time.Time // nil => root token / never expires +} + +// String never includes the token value itself (Info never carries the raw +// token in the first place), but is defined for consistent %v behaviour +// alongside Resolved. +func (i *Info) String() string { + if i == nil { + return "" + } + return fmt.Sprintf("", i.Accessor, i.Policies) +} + +// Lookup calls auth/token/lookup-self and normalises the response. A 403 +// is reported as ErrTokenInvalid rather than propagated as a raw API error. +func Lookup(ctx context.Context, c *api.Client) (*Info, error) { + sec, err := c.Logical().ReadWithContext(ctx, "auth/token/lookup-self") + if err != nil { + kind, _ := vault.Classify(err) + if kind == vault.ErrForbidden || kind == vault.ErrUnauthorized { + return nil, ErrTokenInvalid + } + return nil, err + } + if sec == nil || sec.Data == nil { + return nil, ErrTokenInvalid + } + return infoFromSecret(sec), nil +} + +func infoFromSecret(sec *api.Secret) *Info { + i := &Info{} + i.Accessor, _ = sec.TokenAccessor() + i.Policies, _ = sec.TokenPolicies() + i.Renewable, _ = sec.TokenIsRenewable() + i.TTL, _ = sec.TokenTTL() + i.NumUses, _ = sec.TokenRemainingUses() + i.Meta, _ = sec.TokenMetadata() + + d := sec.Data + i.DisplayName, _ = d["display_name"].(string) + i.EntityID, _ = d["entity_id"].(string) + i.Type, _ = d["type"].(string) + i.Path, _ = d["path"].(string) + i.NamespacePath, _ = d["namespace_path"].(string) + i.Orphan, _ = d["orphan"].(bool) + + if idp, ok := d["identity_policies"].([]interface{}); ok { + for _, p := range idp { + if s, ok := p.(string); ok { + i.IdentityPolicies = append(i.IdentityPolicies, s) + } + } + } + if ct, ok := vault.AsInt(d["creation_ttl"]); ok { + i.CreationTTL = time.Duration(ct) * time.Second + } + if it, ok := d["issue_time"].(string); ok { + if t, err := time.Parse(time.RFC3339, it); err == nil { + i.IssueTime = t + } + } + // expire_time is absent/null for root tokens; handle that explicitly so + // callers can render "never" instead of the zero time. + if et, ok := d["expire_time"].(string); ok && et != "" { + if t, err := time.Parse(time.RFC3339Nano, et); err == nil { + i.ExpireTime = &t + } + } + + return i +} + +// RenewSelf calls auth/token/renew-self. increment == 0 lets Vault choose +// its own TTL extension. +func RenewSelf(ctx context.Context, c *api.Client, increment time.Duration) (*Info, error) { + body := map[string]interface{}{} + if increment > 0 { + body["increment"] = int(increment.Seconds()) + } + sec, err := c.Logical().WriteWithContext(ctx, "auth/token/renew-self", body) + if err != nil { + return nil, err + } + if sec == nil { + return nil, fmt.Errorf("renew-self: empty response") + } + // renew-self's response shape is an auth response (sec.Auth), not a + // lookup-self data map; re-lookup so callers get one consistent Info shape. + return Lookup(ctx, c) +} + +// RevokeSelf calls auth/token/revoke-self and then erases the given store +// (best-effort: erase runs even if the API call itself already invalidated +// the token from Vault's perspective). +func RevokeSelf(ctx context.Context, c *api.Client, s Store) error { + _, err := c.Logical().WriteWithContext(ctx, "auth/token/revoke-self", nil) + if s != nil { + if eraseErr := s.Erase(ctx); eraseErr != nil && err == nil { + err = eraseErr + } + } + return err +} diff --git a/internal/token/resolve.go b/internal/token/resolve.go new file mode 100644 index 0000000..7c797f4 --- /dev/null +++ b/internal/token/resolve.go @@ -0,0 +1,88 @@ +package token + +import ( + "context" + "strings" + + "git.morlana.online/f.weber/vault-tui/internal/config" +) + +// Source identifies which layer a resolved token came from. +type Source uint8 + +const ( + SourceNone Source = iota + SourceFlag + SourceEnv + SourceConfig + SourceStore +) + +func (s Source) String() string { + switch s { + case SourceFlag: + return "flag" + case SourceEnv: + return "env" + case SourceConfig: + return "config" + case SourceStore: + return "store" + default: + return "none" + } +} + +// Resolved is the outcome of Resolve: the token plus enough provenance for +// the status bar to explain itself (a common source of confusion for any +// tool that shadows the Vault CLI's own ~/.vault-token). +// +// String/GoString are overridden so an accidental %v/%+v never leaks the +// token value into a log line. +type Resolved struct { + Token string + Source Source + Origin string // e.g. "VAULT_TOKEN", "~/.vault-token", "/usr/local/bin/vault-token-helper" +} + +func (r Resolved) String() string { + if r.Token == "" { + return "" + } + return "" +} + +func (r Resolved) GoString() string { return r.String() } + +// Options is the input to Resolve. +type Options struct { + Flag string // --token + Env string // VAULT_TOKEN, pre-read by the caller + Profile *config.Profile + Store Store // resolved by internal/cli from profile.token.storage + Getenv func(string) (string, bool) +} + +// Resolve applies the precedence: --token flag > VAULT_TOKEN env > +// profile.token.value (discouraged) > the configured Store. +func Resolve(ctx context.Context, o Options) (Resolved, error) { + if v := strings.TrimSpace(o.Flag); v != "" { + return Resolved{Token: v, Source: SourceFlag, Origin: "--token"}, nil + } + if v := strings.TrimSpace(o.Env); v != "" { + return Resolved{Token: v, Source: SourceEnv, Origin: "VAULT_TOKEN"}, nil + } + if o.Profile != nil && o.Profile.Token.Value != "" { + return Resolved{Token: o.Profile.Token.Value, Source: SourceConfig, Origin: "config token.value"}, nil + } + if o.Store != nil { + tok, err := o.Store.Get(ctx) + if err != nil { + return Resolved{}, err + } + if tok != "" { + return Resolved{Token: tok, Source: SourceStore, Origin: o.Store.Location()}, nil + } + } + return Resolved{Source: SourceNone}, nil +} diff --git a/internal/token/store.go b/internal/token/store.go new file mode 100644 index 0000000..5d2343c --- /dev/null +++ b/internal/token/store.go @@ -0,0 +1,31 @@ +// Package token owns everything about where a Vault token lives between +// runs: resolving one from flag/env/config/disk, validating it, keeping it +// renewed, and writing it back out — including in a form the real Vault CLI +// can read (see store_vaultcli.go). +package token + +import "context" + +// Store persists a single token value. Implementations must not log the +// token value anywhere. +type Store interface { + // Kind identifies the implementation for display: "vault-cli", "profile", + // or "none". + Kind() string + // Location is a display string: the token file path, or the external + // helper binary path. + Location() string + Get(ctx context.Context) (string, error) + Store(ctx context.Context, token string) error + Erase(ctx context.Context) error +} + +// noneStore never persists anything; used when token.storage: none. +type noneStore struct{} + +func NewNoneStore() Store { return noneStore{} } +func (noneStore) Kind() string { return "none" } +func (noneStore) Location() string { return "" } +func (noneStore) Get(context.Context) (string, error) { return "", nil } +func (noneStore) Store(context.Context, string) error { return nil } +func (noneStore) Erase(context.Context) error { return nil } diff --git a/internal/token/store_profile.go b/internal/token/store_profile.go new file mode 100644 index 0000000..a64b5fd --- /dev/null +++ b/internal/token/store_profile.go @@ -0,0 +1,68 @@ +package token + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/natefinch/atomic" +) + +// profileStore keeps one token per profile under the XDG state directory. +// It exists because vault-cli storage is single-valued (~/.vault-token): +// logging into "prod" would silently clobber "dev". Uses the same +// write-then-atomic-rename pattern as Vault's own InternalTokenHelper. +type profileStore struct { + path string +} + +// NewProfileStore returns a Store scoped to one profile name, rooted at +// stateDir (see config.StateDir), e.g. stateDir/tokens/.token. +func NewProfileStore(stateDir, profile string) Store { + return profileStore{path: filepath.Join(stateDir, "tokens", sanitize(profile)+".token")} +} + +// NewFileStore is a profileStore pinned to an explicit path, used for +// token.file in the config. +func NewFileStore(path string) Store { + return profileStore{path: path} +} + +func sanitize(name string) string { + return strings.NewReplacer("/", "_", "\\", "_", "..", "_").Replace(name) +} + +func (s profileStore) Kind() string { return "profile" } +func (s profileStore) Location() string { return s.path } + +func (s profileStore) Get(context.Context) (string, error) { + b, err := os.ReadFile(s.path) + if os.IsNotExist(err) { + return "", nil + } + if err != nil { + return "", err + } + return strings.TrimSpace(string(b)), nil +} + +func (s profileStore) Store(_ context.Context, tok string) error { + dir := filepath.Dir(s.path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("creating token directory %q: %w", dir, err) + } + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, []byte(tok), 0o600); err != nil { + return err + } + return atomic.ReplaceFile(tmp, s.path) +} + +func (s profileStore) Erase(context.Context) error { + if err := os.Remove(s.path); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} diff --git a/internal/token/store_vaultcli.go b/internal/token/store_vaultcli.go new file mode 100644 index 0000000..24d8154 --- /dev/null +++ b/internal/token/store_vaultcli.go @@ -0,0 +1,51 @@ +package token + +import ( + "context" + "fmt" + + "github.com/hashicorp/vault/api/cliconfig" + "github.com/hashicorp/vault/api/tokenhelper" +) + +// vaultCLIStore delegates to the exact packages the Vault CLI itself uses, +// so ~/.vault-token stays byte-compatible and any token_helper configured in +// ~/.vault (HCL file, overridable via VAULT_CONFIG_PATH) is honoured. Both +// api/cliconfig and api/tokenhelper ship inside the api module we already +// depend on, so this costs zero additional dependencies. +type vaultCLIStore struct { + h tokenhelper.TokenHelper +} + +// NewVaultCLIStore resolves the configured token helper (falling back to +// the internal ~/.vault-token helper when none is configured). +func NewVaultCLIStore() (Store, error) { + h, err := cliconfig.DefaultTokenHelper() + if err != nil { + return nil, fmt.Errorf("resolving vault token helper: %w", err) + } + return vaultCLIStore{h: h}, nil +} + +func (s vaultCLIStore) Kind() string { return "vault-cli" } + +// Location calls Get first: InternalTokenHelper.Path() is only populated +// after its first Get/Store/Erase call (it lazily resolves the home +// directory internally), so an unconditional Path() call before any other +// operation would return "". +func (s vaultCLIStore) Location() string { + _, _ = s.h.Get() + return s.h.Path() +} + +func (s vaultCLIStore) Get(context.Context) (string, error) { + return s.h.Get() +} + +func (s vaultCLIStore) Store(_ context.Context, tok string) error { + return s.h.Store(tok) +} + +func (s vaultCLIStore) Erase(context.Context) error { + return s.h.Erase() +} diff --git a/internal/ui/chrome.go b/internal/ui/chrome.go new file mode 100644 index 0000000..b1e6a33 --- /dev/null +++ b/internal/ui/chrome.go @@ -0,0 +1,192 @@ +package ui + +import ( + "fmt" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/lipgloss/v2" +) + +// screenHelp is the shape every keys.KeyMap.XxxHelp() screen-help value +// satisfies; declared here (rather than importing the concrete type, which +// is unexported in package keys) so viewFooter can take any of them. +type screenHelp interface { + ShortHelp() []key.Binding + FullHelp() [][]key.Binding +} + +var _ help.KeyMap = screenHelp(nil) // screenHelp is exactly help.KeyMap + +// breadcrumbSegments returns one path element per frame on the stack, plus +// the leading profile name — the pieces viewHeader joins with symChevronR +// and, on a narrow terminal, elides from the middle. +func (m *Model) breadcrumbSegments() []string { + segs := []string{m.a.Settings.Profile} + for _, f := range m.stack { + switch f.scr { + case scrMounts: + segs = append(segs, "mounts") + case scrBrowser, scrSecret: + segs = append(segs, f.mount.Path+f.path) + case scrEditor: + segs = append(segs, "edit") + case scrVersions: + segs = append(segs, "versions") + } + } + return segs +} + +// renderBreadcrumb joins breadcrumbSegments with " ▸ ", eliding from the +// middle (keeping the profile name and the current location legible) when +// the full trail doesn't fit in avail cells. +func (m *Model) renderBreadcrumb(avail int) string { + segs := m.breadcrumbSegments() + sep := m.styles.CrumbSep.Render(" " + symChevronR + " ") + join := func(ss []string) string { + styled := make([]string, len(ss)) + last := len(ss) - 1 + for i, s := range ss { + if i == last { + styled[i] = m.styles.CrumbActive.Render(s) + } else { + styled[i] = m.styles.Crumb.Render(s) + } + } + out := styled[0] + for _, s := range styled[1:] { + out += sep + s + } + return out + } + + full := join(segs) + if lipgloss.Width(full) <= avail || len(segs) <= 2 { + if lipgloss.Width(full) <= avail { + return full + } + return m.styles.CrumbActive.Render(truncate(segs[len(segs)-1], avail)) + } + + elided := append([]string{segs[0], symEllipsis}, segs[len(segs)-1]) + out := join(elided) + if lipgloss.Width(out) <= avail { + return out + } + return m.styles.CrumbActive.Render(truncate(segs[len(segs)-1], avail)) +} + +// ttlPill renders the token-TTL pill, colored by how close it is to +// Settings.TTLWarnBelow. +func (m *Model) ttlPill() string { + if m.tokenInfo == nil || m.tokenInfo.ExpireTime == nil { + return "" + } + ttl := m.tokenInfo.TTL + text := "ttl " + ttlString(ttl) + warnAt := m.a.Settings.TTLWarnBelow + switch { + case ttl <= 0: + return m.styles.PillDanger.Render(text) + case warnAt > 0 && ttl <= warnAt/4: + return m.styles.PillDanger.Render(text) + case warnAt > 0 && ttl <= warnAt: + return m.styles.PillWarn.Render(text) + default: + return m.styles.Pill.Render(text) + } +} + +// viewHeader renders the single-line top bar: app badge + breadcrumb on +// the left, status pills on the right. Collapses to a bare breadcrumb on +// sizeTiny, where there's no room for a badge. +func (m *Model) viewHeader() string { + var right []string + if m.a.Settings.ReadOnly { + right = append(right, m.styles.PillWarn.Render("READ-ONLY")) + } + if m.a.Settings.Namespace != "" && m.lay.class != sizeTiny { + right = append(right, m.styles.Pill.Render("ns:"+m.a.Settings.Namespace)) + } + if p := m.ttlPill(); p != "" { + right = append(right, p) + } + if m.loading { + right = append(right, m.spin.View()) + } + rightStr := lipgloss.JoinHorizontal(lipgloss.Center, right...) + rightW := lipgloss.Width(rightStr) + if rightStr != "" { + rightStr = " " + rightStr + rightW++ + } + + badge := "" + if m.lay.class != sizeTiny { + badge = m.styles.AppBadge.Render("▮ VAULT") + " " + } + badgeW := lipgloss.Width(badge) + + // HeaderBar carries its own Padding(0,1) — a style's declared Width sets + // the pre-padding content width, so the bar renders at contentW+2. Budget + // for that here rather than sizing to the full bar width, or the result + // overflows the terminal by 2 cells and wraps. + barW := m.lay.w - 2 + if barW < 1 { + barW = 1 + } + + avail := barW - badgeW - rightW + if avail < 4 { + avail = 4 + } + crumb := m.renderBreadcrumb(avail) + crumbW := lipgloss.Width(crumb) + + pad := barW - badgeW - crumbW - rightW + if pad < 0 { + pad = 0 + } + line := badge + crumb + lipgloss.NewStyle().Width(pad).Render("") + rightStr + return m.styles.HeaderBar.Render(fit(line, barW, 1)) +} + +// viewFooter renders the footer: a screen-specific key hint line plus a +// status line (profile/address, or the current toast) — one line total on +// sizeTiny, two otherwise. +func (m *Model) viewFooter(sh screenHelp) string { + status := fmt.Sprintf("%s @ %s", m.a.Settings.Profile, m.a.Settings.Address) + if toastStr := m.toastView(); toastStr != "" { + status = toastStr + } else { + status = m.styles.StatusBar.Render(status) + } + + // Footer carries Padding(0,1) too — same barW budgeting as viewHeader. + barW := m.lay.w - 2 + if barW < 1 { + barW = 1 + } + m.help.SetWidth(barW) + + if m.lay.class == sizeTiny { + line := status + if m.toast == nil { + line = m.styles.Footer.Render(m.help.ShortHelpView(sh.ShortHelp())) + } + return m.styles.Footer.Render(fit(line, barW, 1)) + } + + helpLine := m.styles.Footer.Render(fit(m.help.ShortHelpView(sh.ShortHelp()), barW, 1)) + statusLine := m.styles.Footer.Render(fit(status, barW, 1)) + return lipgloss.JoinVertical(lipgloss.Left, helpLine, statusLine) +} + +// viewTooSmall replaces the entire UI below layout.tooSmall's threshold. +func (m *Model) viewTooSmall() string { + msg := fmt.Sprintf("terminal too small\n%dx%d — need at least %dx%d", m.lay.w, m.lay.h, minWidth, minHeight) + box := m.styles.Modal.Render(msg) + return lipgloss.Place(m.lay.w, m.lay.h, lipgloss.Center, lipgloss.Center, box, + lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(m.styles.Bg))) +} diff --git a/internal/ui/confirm.go b/internal/ui/confirm.go new file mode 100644 index 0000000..cb5637c --- /dev/null +++ b/internal/ui/confirm.go @@ -0,0 +1,60 @@ +package ui + +import ( + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +func (m *Model) updateConfirm(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + c := m.confirm + if c.typeToConf != "" { + switch msg.String() { + case "esc": + m.confirm = nil + return m, nil + case "enter": + if c.input == c.typeToConf { + cmd := c.onConfirm() + m.confirm = nil + return m, cmd + } + return m, nil + case "backspace": + if len(c.input) > 0 { + c.input = c.input[:len(c.input)-1] + } + return m, nil + default: + if len(msg.Text) > 0 { + c.input += msg.Text + } + return m, nil + } + } + switch msg.String() { + case "y", "enter": + cmd := c.onConfirm() + m.confirm = nil + return m, cmd + case "n", "esc": + m.confirm = nil + return m, nil + } + return m, nil +} + +func (m *Model) viewConfirm() string { + c := m.confirm + style := m.styles.Modal + if c.danger { + style = m.styles.ModalDanger + } + w := m.modalWrapWidth() + lines := []string{m.styles.ModalTitle.Render(c.title), lipgloss.Wrap(c.body, w, ""), ""} + if c.typeToConf != "" { + lines = append(lines, "Type "+c.typeToConf+" to confirm:", "> "+c.input) + } else { + lines = append(lines, m.styles.Help.Render("y/enter confirm • n/esc cancel")) + } + return style.Render(lipgloss.JoinVertical(lipgloss.Left, lines...)) +} diff --git a/internal/ui/delegate.go b/internal/ui/delegate.go new file mode 100644 index 0000000..94cd791 --- /dev/null +++ b/internal/ui/delegate.go @@ -0,0 +1,150 @@ +package ui + +import ( + "fmt" + "io" + + "charm.land/bubbles/v2/list" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "git.morlana.online/f.weber/vault-tui/internal/ui/theme" + "git.morlana.online/f.weber/vault-tui/internal/vault" +) + +// entryItem implements list.Item for every row shown in the mounts and +// browser screens: a secret mount, a "directory" (a KV path prefix), or a +// leaf secret. +type entryItem struct { + label string // display text, e.g. "secret/" or "team/prod/" + desc string + mount vault.Mount // set for mount rows and (copied through) for dir/leaf rows + relPath string // path relative to mount, "" at mount root + isMount bool + isDir bool + unsupported bool +} + +func (e entryItem) FilterValue() string { return e.label } + +func mountItems(mounts []vault.Mount) []entryItem { + items := make([]entryItem, 0, len(mounts)) + for _, mnt := range mounts { + desc := mnt.Type + if mnt.Description != "" { + desc = fmt.Sprintf("%s — %s", mnt.Type, mnt.Description) + } + items = append(items, entryItem{ + label: mnt.Path, desc: desc, mount: mnt, isMount: true, unsupported: !mnt.Supported(), + }) + } + return items +} + +func listingItems(mount vault.Mount, listing vault.Listing) []entryItem { + items := make([]entryItem, 0, len(listing.Dirs)+len(listing.Leaves)) + for _, d := range listing.Dirs { + items = append(items, entryItem{label: d, desc: "directory", mount: mount, relPath: listing.Path + d, isDir: true}) + } + for _, l := range listing.Leaves { + items = append(items, entryItem{label: l, desc: "secret", mount: mount, relPath: listing.Path + l}) + } + return items +} + +// entryDelegate is a from-scratch list.ItemDelegate: a cursor bar, a type +// symbol, the label (with filter matches highlighted), and a right-aligned +// badge — replacing bubbles/list's DefaultDelegate, which has no notion of +// our entry types or theme. +type entryDelegate struct { + styles *theme.Styles +} + +func (d entryDelegate) Height() int { return 1 } +func (d entryDelegate) Spacing() int { return 0 } +func (d entryDelegate) Update(tea.Msg, *list.Model) tea.Cmd { return nil } + +func (d entryDelegate) Render(w io.Writer, lm list.Model, index int, item list.Item) { + e, ok := item.(entryItem) + if !ok { + return + } + width := lm.Width() + if width <= 0 { + return + } + selected := index == lm.Index() && lm.FilterState() != list.Filtering + + sym, badge := symSecret, "secret" + switch { + case e.isMount: + sym, badge = symMount, engineLabel(e.mount) + case e.isDir: + sym, badge = symDir, "dir" + case e.unsupported: + sym, badge = symUnsupported, e.mount.Type+" (unsupported)" + } + + cursor := " " + if selected { + cursor = d.styles.Title.Render(symCursor) + " " + } + cursorW := lipgloss.Width(cursor) + + badgeStr := d.styles.Badge.Render(badge) + if selected { + badgeStr = d.styles.RowSelected.Render(" " + badge + " ") + } + badgeW := lipgloss.Width(badgeStr) + + labelW := width - cursorW - badgeW - 2 + if labelW < 4 { + labelW = 4 + } + + base := d.rowStyle(e, selected) + label := truncate(sym+" "+e.label, labelW) + highlighted := false + if lm.FilterState() == list.Filtering || lm.FilterState() == list.FilterApplied { + if matches := lm.MatchesForItem(index); len(matches) > 0 { + unmatched := base.Inline(true) + matched := unmatched.Underline(true) + label = lipgloss.StyleRunes(label, shiftMatches(matches, 2), matched, unmatched) + highlighted = true + } + } + if !highlighted { + label = base.Render(label) + } + + pad := width - cursorW - lipgloss.Width(label) - badgeW + if pad < 1 { + pad = 1 + } + fmt.Fprint(w, cursor+label+lipgloss.NewStyle().Width(pad-1).Render("")+" "+badgeStr) +} + +// rowStyle picks the base row style for an entry, accounting for +// selection and the dimmed "unsupported engine" case. +func (d *entryDelegate) rowStyle(e entryItem, selected bool) lipgloss.Style { + switch { + case selected: + return d.styles.RowSelected + case e.unsupported: + return d.styles.RowDim + case e.isDir: + return d.styles.DirText + default: + return d.styles.Row + } +} + +// shiftMatches offsets filter-match rune indices by the width of the +// symbol+space prefix Render prepends to the label before highlighting. +func shiftMatches(matches []int, offset int) []int { + out := make([]int, len(matches)) + for i, m := range matches { + out[i] = m + offset + } + return out +} diff --git a/internal/ui/format.go b/internal/ui/format.go new file mode 100644 index 0000000..2ead379 --- /dev/null +++ b/internal/ui/format.go @@ -0,0 +1,166 @@ +package ui + +import ( + "fmt" + "strings" + "time" + + "charm.land/lipgloss/v2" + + "git.morlana.online/f.weber/vault-tui/internal/vault" +) + +// Symbols used throughout the TUI. Deliberately plain, single-width +// Unicode box-drawing/geometric shapes — no Nerd Font glyphs, no emoji — +// so rendering never depends on a font the user's terminal might not have. +const ( + symMount = "▣" + symDir = "▸" + symSecret = "▪" + symUnsupported = "▨" + symCursor = "┃" + symCurrent = "●" + symDeleted = "◐" + symDestroyed = "✕" + symOK = "✓" + symWarn = "⚠" + symErr = "✕" + symLocked = "●" + symUnlocked = "○" + symChevronR = "▸" + symEllipsis = "…" +) + +// truncate clips s to at most w cells wide, replacing the tail with an +// ellipsis when it doesn't fit — unlike fit/MaxWidth, which hard-crop +// without any indication that content was cut off. +func truncate(s string, w int) string { + if w <= 0 { + return "" + } + if lipgloss.Width(s) <= w { + return s + } + if w == 1 { + return symEllipsis + } + runes := []rune(s) + // Binary-search-free linear trim: widths are small (labels/paths), and + // wide runes are rare enough that a byte-at-a-time trim is plenty fast. + for i := len(runes); i > 0; i-- { + cand := string(runes[:i]) + symEllipsis + if lipgloss.Width(cand) <= w { + return cand + } + } + return symEllipsis +} + +// padRight pads s with spaces to exactly w cells, or truncates it if it's +// already wider — for building fixed-width table cells inline (as opposed +// to fit, which pads/crops a whole multi-line block). +func padRight(s string, w int) string { + if w <= 0 { + return "" + } + lw := lipgloss.Width(s) + if lw > w { + return truncate(s, w) + } + return s + strings.Repeat(" ", w-lw) +} + +// padLeft right-aligns s within w cells. +func padLeft(s string, w int) string { + if w <= 0 { + return "" + } + lw := lipgloss.Width(s) + if lw >= w { + return truncate(s, w) + } + return strings.Repeat(" ", w-lw) + s +} + +// humanTime renders an RFC3339 timestamp (as returned by Vault's KV +// metadata) as a short relative time, falling back to the raw string if it +// doesn't parse. +func humanTime(rfc3339 string) string { + if rfc3339 == "" { + return "" + } + t, err := time.Parse(time.RFC3339, rfc3339) + if err != nil { + return rfc3339 + } + d := time.Since(t) + switch { + case d < 0: + return t.Format("2006-01-02 15:04") + case d < time.Minute: + return "just now" + case d < time.Hour: + return fmt.Sprintf("%dm ago", int(d/time.Minute)) + case d < 24*time.Hour: + return fmt.Sprintf("%dh ago", int(d/time.Hour)) + case d < 30*24*time.Hour: + return fmt.Sprintf("%dd ago", int(d/(24*time.Hour))) + default: + return t.Format("2006-01-02") + } +} + +// ttlString renders a duration the way a status pill wants it: compact, +// never more than two units ("42m", "1h05m", "3d02h"), never negative. +func ttlString(d time.Duration) string { + if d <= 0 { + return "expired" + } + d = d.Round(time.Second) + days := d / (24 * time.Hour) + d -= days * 24 * time.Hour + hours := d / time.Hour + d -= hours * time.Hour + mins := d / time.Minute + d -= mins * time.Minute + secs := d / time.Second + + switch { + case days > 0: + return fmt.Sprintf("%dd%02dh", days, hours) + case hours > 0: + return fmt.Sprintf("%dh%02dm", hours, mins) + case mins > 0: + return fmt.Sprintf("%dm%02ds", mins, secs) + default: + return fmt.Sprintf("%ds", secs) + } +} + +// engineLabel is the short badge text for a mount's engine kind. +func engineLabel(m vault.Mount) string { + switch m.Kind { + case vault.EngineKVv2: + return "kv v2" + case vault.EngineKVv1: + return "kv v1" + default: + return m.Type + } +} + +// maskValue replaces v with repeated mask characters, capped at a fixed +// display width so long secrets don't stretch the layout. +func maskValue(v, char string) string { + if char == "" { + char = "•" + } + n := lipgloss.Width(v) + if n > 12 { + n = 12 + } + if n == 0 { + n = 1 + } + return strings.Repeat(char, n) +} diff --git a/internal/ui/keys/help.go b/internal/ui/keys/help.go new file mode 100644 index 0000000..c6a924a --- /dev/null +++ b/internal/ui/keys/help.go @@ -0,0 +1,74 @@ +package keys + +import "charm.land/bubbles/v2/key" + +// screenHelp implements bubbles/help's Model.KeyMap with a fixed +// short/full binding set — one instance per screen, built fresh from the +// live *KeyMap so a `keys:` rebind or DisableWrites (read-only mode) is +// reflected without the screen needing to know about it. +type screenHelp struct { + short []key.Binding + full [][]key.Binding +} + +func (h screenHelp) ShortHelp() []key.Binding { return h.short } +func (h screenHelp) FullHelp() [][]key.Binding { return h.full } + +// BrowserHelp is the footer for the mounts/listing screens. +func (k *KeyMap) BrowserHelp() screenHelp { + return screenHelp{ + short: []key.Binding{k.Up, k.Down, k.Enter, k.Back, k.Filter, k.New, k.Help, k.Quit}, + full: [][]key.Binding{ + {k.Up, k.Down, k.Top, k.Bottom, k.Enter, k.Back}, + {k.Filter, k.Refresh, k.New, k.Profiles, k.Logout}, + {k.Help, k.Quit, k.ForceQuit}, + }, + } +} + +// SecretHelp is the footer for the secret detail screen. +func (k *KeyMap) SecretHelp() screenHelp { + return screenHelp{ + short: []key.Binding{k.Up, k.Down, k.ToggleMask, k.CopyValue, k.Edit, k.Back, k.Help}, + full: [][]key.Binding{ + {k.Up, k.Down, k.Back, k.Versions}, + {k.ToggleMask, k.ToggleMaskAll, k.CopyValue}, + {k.Edit, k.Delete, k.Destroy}, + {k.Help, k.Quit, k.ForceQuit}, + }, + } +} + +// EditorHelp is the footer for the create/edit-secret screen. +func (k *KeyMap) EditorHelp() screenHelp { + return screenHelp{ + short: []key.Binding{k.Save, k.AddField, k.DeleteField, k.Cancel}, + full: [][]key.Binding{ + {k.Save, k.AddField, k.DeleteField, k.Cancel}, + }, + } +} + +// VersionsHelp is the footer for the KV v2 version-history screen. +func (k *KeyMap) VersionsHelp() screenHelp { + return screenHelp{ + short: []key.Binding{k.Up, k.Down, k.Enter, k.Rollback, k.Undelete, k.Back}, + full: [][]key.Binding{ + {k.Up, k.Down, k.Enter, k.Back}, + {k.Rollback, k.Undelete}, + {k.Help, k.Quit, k.ForceQuit}, + }, + } +} + +// AuthHelp is the footer for the auth-method picker phase (the form and +// waiting phases use their own fixed, non-rebindable hints since tab/ +// enter/esc there are text-navigation, not KeyMap actions). +func (k *KeyMap) AuthHelp() screenHelp { + return screenHelp{ + short: []key.Binding{k.Up, k.Down, k.Enter, k.Quit}, + full: [][]key.Binding{ + {k.Up, k.Down, k.Enter, k.Quit, k.ForceQuit}, + }, + } +} diff --git a/internal/ui/keys/keymap.go b/internal/ui/keys/keymap.go new file mode 100644 index 0000000..7b2cd96 --- /dev/null +++ b/internal/ui/keys/keymap.go @@ -0,0 +1,127 @@ +// Package keys defines the TUI's keybindings and how config.File's `keys:` +// section can rebind them. +package keys + +import "charm.land/bubbles/v2/key" + +// KeyMap holds every action the TUI's root Update dispatches on. Screens +// consult the same KeyMap so a rebind in config.yaml takes effect +// everywhere at once. +type KeyMap struct { + Up, Down key.Binding + Top, Bottom key.Binding + Enter, Back key.Binding + Refresh key.Binding + Filter key.Binding + Palette key.Binding + ToggleMask key.Binding + ToggleMaskAll key.Binding + CopyValue key.Binding + Versions key.Binding + Rollback key.Binding + Undelete key.Binding + New, Edit key.Binding + Save, Cancel key.Binding + AddField key.Binding + DeleteField key.Binding + Delete, Destroy key.Binding + Profiles key.Binding + Logout key.Binding + Help, Quit key.Binding + ForceQuit key.Binding +} + +// Default returns the built-in vim-flavoured keymap. +func Default() *KeyMap { + return &KeyMap{ + Up: key.NewBinding(key.WithKeys("k", "up"), key.WithHelp("k/↑", "up")), + Down: key.NewBinding(key.WithKeys("j", "down"), key.WithHelp("j/↓", "down")), + Top: key.NewBinding(key.WithKeys("g", "home"), key.WithHelp("g", "top")), + Bottom: key.NewBinding(key.WithKeys("G", "end"), key.WithHelp("G", "bottom")), + Enter: key.NewBinding(key.WithKeys("enter", "l"), key.WithHelp("↵", "open")), + Back: key.NewBinding(key.WithKeys("esc", "h"), key.WithHelp("esc", "back")), + Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")), + Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")), + Palette: key.NewBinding(key.WithKeys(":"), key.WithHelp(":", "goto path")), + ToggleMask: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "show/hide value")), + ToggleMaskAll: key.NewBinding(key.WithKeys("S"), key.WithHelp("S", "show/hide all")), + CopyValue: key.NewBinding(key.WithKeys("y"), key.WithHelp("y", "copy value")), + Versions: key.NewBinding(key.WithKeys("V"), key.WithHelp("V", "versions")), + Rollback: key.NewBinding(key.WithKeys("R"), key.WithHelp("R", "rollback to this version")), + Undelete: key.NewBinding(key.WithKeys("u"), key.WithHelp("u", "undelete this version")), + New: key.NewBinding(key.WithKeys("n"), key.WithHelp("n", "new secret")), + Edit: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")), + Save: key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("^s", "save")), + Cancel: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")), + AddField: key.NewBinding(key.WithKeys("ctrl+a"), key.WithHelp("^a", "add field")), + DeleteField: key.NewBinding(key.WithKeys("ctrl+x"), key.WithHelp("^x", "remove field")), + Delete: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")), + Destroy: key.NewBinding(key.WithKeys("D"), key.WithHelp("D", "destroy")), + Profiles: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "switch profile")), + Logout: key.NewBinding(key.WithKeys("ctrl+l"), key.WithHelp("^l", "logout")), + Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), + Quit: key.NewBinding(key.WithKeys("q"), key.WithHelp("q", "quit")), + ForceQuit: key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("^c", "quit")), + } +} + +func (k *KeyMap) registry() map[string]*key.Binding { + return map[string]*key.Binding{ + "up": &k.Up, "down": &k.Down, "top": &k.Top, "bottom": &k.Bottom, + "enter": &k.Enter, "back": &k.Back, "refresh": &k.Refresh, "filter": &k.Filter, + "palette": &k.Palette, "toggle_mask": &k.ToggleMask, "toggle_mask_all": &k.ToggleMaskAll, + "copy_value": &k.CopyValue, "versions": &k.Versions, "rollback": &k.Rollback, + "undelete": &k.Undelete, "new": &k.New, "edit": &k.Edit, "save": &k.Save, + "cancel": &k.Cancel, "add_field": &k.AddField, "delete_field": &k.DeleteField, + "delete": &k.Delete, "destroy": &k.Destroy, "profiles": &k.Profiles, "logout": &k.Logout, + "help": &k.Help, "quit": &k.Quit, "force_quit": &k.ForceQuit, + } +} + +// Apply overrides the defaults from config.File's `keys:` map. An unknown +// action name is a startup error, not a silently ignored typo. +func (k *KeyMap) Apply(cfg map[string][]string) error { + reg := k.registry() + for action, keyStrs := range cfg { + b, ok := reg[action] + if !ok { + return unknownActionError(action) + } + *b = key.NewBinding(key.WithKeys(keyStrs...), key.WithHelp(b.Help().Key, b.Help().Desc)) + } + return nil +} + +type unknownActionError string + +func (e unknownActionError) Error() string { + return "unknown keybinding action " + string(e) + " in config `keys:` section" +} + +// DisableWrites turns off every write-capable binding — the UI-side half of +// read-only enforcement (the real guard is vault.KV.ReadOnly). +func (k *KeyMap) DisableWrites() { + for _, b := range []*key.Binding{ + &k.New, &k.Edit, &k.Save, &k.AddField, &k.DeleteField, + &k.Delete, &k.Destroy, &k.Rollback, &k.Undelete, + } { + b.SetEnabled(false) + } +} + +// ShortHelp/FullHelp implement help.KeyMap for the global bindings and +// back the "?" help overlay (screen_help.go). Individual screens' footers +// use the narrower, screen-specific views in help.go instead. +func (k *KeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Up, k.Down, k.Enter, k.Back, k.Help, k.Quit} +} + +func (k *KeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Up, k.Down, k.Top, k.Bottom, k.Enter, k.Back}, + {k.Refresh, k.Filter, k.Palette, k.ToggleMask, k.ToggleMaskAll, k.CopyValue}, + {k.Versions, k.Rollback, k.Undelete, k.New, k.Edit, k.Save}, + {k.Cancel, k.AddField, k.DeleteField, k.Delete, k.Destroy}, + {k.Profiles, k.Logout, k.Help, k.Quit, k.ForceQuit}, + } +} diff --git a/internal/ui/layout.go b/internal/ui/layout.go new file mode 100644 index 0000000..58c1d96 --- /dev/null +++ b/internal/ui/layout.go @@ -0,0 +1,147 @@ +package ui + +import ( + "strings" + + "charm.land/lipgloss/v2" +) + +// sizeClass buckets the terminal width into the breakpoints every screen's +// view function reads to decide how much chrome it can afford. +type sizeClass int + +const ( + sizeTiny sizeClass = iota // < tinyWidth columns: single column, minimal chrome + sizeCompact // < wideWidth columns: single column, full chrome + sizeWide // >= wideWidth columns: room for a side panel +) + +const ( + minWidth, minHeight = 44, 12 // below this, render the "too small" notice instead + tinyWidth = 60 + wideWidth = 100 + + // Panel border(1)+padding(0,1) on each side: 2 cols border, 2 cols + // padding, 2 rows border, 0 rows padding. Every screen that renders + // inside styles.Panel/PanelActive/SidePanel must size its content to + // panelInner of the outer box it's about to be wrapped in. + panelBorderW, panelBorderH = 2, 2 + panelPadW, panelPadH = 2, 0 +) + +// layout is the single source of truth for how the terminal's cells are +// divided, recomputed once per tea.WindowSizeMsg and read by every view +// function instead of each screen re-deriving its own magic numbers. +type layout struct { + w, h int + class sizeClass + tooSmall bool + + headerH, footerH int + bodyW, bodyH int // full-width body area below the header, above the footer + + // Two-column split of bodyW, valid only when class == sizeWide and a + // screen opts into it (mainW+sideW+1 == bodyW; the extra column is a + // one-cell gutter between the two panels). + mainW, sideW int +} + +func computeLayout(w, h int) layout { + l := layout{w: w, h: h} + if w < minWidth || h < minHeight { + l.tooSmall = true + return l + } + + switch { + case w < tinyWidth: + l.class = sizeTiny + case w < wideWidth: + l.class = sizeCompact + default: + l.class = sizeWide + } + + l.headerH = 1 + l.footerH = 2 + if l.class == sizeTiny { + l.footerH = 1 + } + l.bodyH = h - l.headerH - l.footerH + if l.bodyH < 3 { + l.bodyH = 3 + } + l.bodyW = w + + if l.class == sizeWide { + l.sideW = l.bodyW * 2 / 5 + if l.sideW > 48 { + l.sideW = 48 + } + l.mainW = l.bodyW - l.sideW - 1 + } else { + l.mainW = l.bodyW + } + return l +} + +// panelInner returns the content area available inside a bordered, +// (0,1)-padded panel of the given outer size — what a screen must wrap its +// text to before handing it to styles.Panel/PanelActive/SidePanel.Render. +func panelInner(outerW, outerH int) (w, h int) { + w = outerW - panelBorderW - panelPadW + h = outerH - panelBorderH - panelPadH + if w < 1 { + w = 1 + } + if h < 1 { + h = 1 + } + return +} + +// fit pads/truncates s to exactly h lines of at most w cells each, so a +// panel's rendered size never depends on its content — no reflow, no +// leftover scrollback in the alt-screen buffer. +func fit(s string, w, h int) string { + if w < 1 { + w = 1 + } + if h < 1 { + h = 1 + } + lines := strings.Split(s, "\n") + out := make([]string, h) + for i := 0; i < h; i++ { + if i >= len(lines) { + out[i] = "" + continue + } + line := lines[i] + lw := lipgloss.Width(line) + switch { + case lw > w: + out[i] = lipgloss.NewStyle().MaxWidth(w).Render(line) + case lw < w: + out[i] = line + strings.Repeat(" ", w-lw) + default: + out[i] = line + } + } + return strings.Join(out, "\n") +} + +// renderPanel wraps content (already fit to panelInner(outerW, outerH)) in +// style, sized so the rendered result is exactly outerW x outerH. +// +// Deliberately does NOT call style.Width()/Height(): lipgloss.Style.Render +// treats a set Width as a word-wrap target of width-minus-border-minus- +// padding and re-wraps anything wider, which corrupts content we've +// already sized to the exact cell grid ourselves via fit(). Handing it +// pre-fit, uniform-width content and leaving Width/Height unset gets the +// same result (border+padding add their fixed, known cost on top) without +// that reprocessing. +func renderPanel(style lipgloss.Style, outerW, outerH int, content string) string { + iw, ih := panelInner(outerW, outerH) + return style.Render(fit(content, iw, ih)) +} diff --git a/internal/ui/model.go b/internal/ui/model.go new file mode 100644 index 0000000..8015f76 --- /dev/null +++ b/internal/ui/model.go @@ -0,0 +1,341 @@ +package ui + +import ( + "context" + "time" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/list" + "charm.land/bubbles/v2/spinner" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "git.morlana.online/f.weber/vault-tui/internal/auth" + "git.morlana.online/f.weber/vault-tui/internal/cli" + "git.morlana.online/f.weber/vault-tui/internal/token" + "git.morlana.online/f.weber/vault-tui/internal/ui/keys" + "git.morlana.online/f.weber/vault-tui/internal/ui/theme" + "git.morlana.online/f.weber/vault-tui/internal/vault" +) + +type screen int + +const ( + scrAuth screen = iota + scrMounts + scrBrowser + scrSecret + scrEditor + scrVersions +) + +// frame is one entry in the navigation stack. Not every field is used by +// every screen kind; see the per-screen handlers in the other files in +// this package. +type frame struct { + scr screen + mount vault.Mount + path string // relative path: browser's current dir, or the secret path +} + +// confirmSpec describes a pending destructive action, rendered as a +// centered overlay (see overlay.go) rather than appended below the +// current screen. +type confirmSpec struct { + title string + body string + danger bool + typeToConf string // if non-empty, the user must type this text to confirm + input string + onConfirm func() tea.Cmd +} + +// authState holds everything the auth screen needs across its three +// sub-phases (method picker -> credential form -> waiting on a +// multi-step login such as OIDC or Okta). +type authState struct { + phase int // 0=picker 1=form 2=waiting + methods []auth.Method + cursor int + method auth.Method + mount string + inputs []textInputField + focus int + events chan auth.Event + cancel context.CancelFunc + authURL string + status []string + err error +} + +type textInputField struct { + field auth.Field + input textInput +} + +// profileState drives the profile-switch overlay (screen_profile.go). +type profileState struct { + open bool + names []string + cursor int +} + +type Model struct { + ctx context.Context + a *cli.App + svc *vault.Service + + styles *theme.Styles + keys *keys.KeyMap + help help.Model + spin spinner.Model + + lay layout + loading bool + + toast *toast + toastSeq int + + stack []frame + auth authState + + list list.Model + listReady bool + listSelect string // remembered selected item's label, restored after a reload + + secret *vault.Secret + secretMask map[string]bool + secretKeys []string // sorted cache of secret.Data's keys, rebuilt on load + secretCursor int + secretVP viewport.Model + clipSeq int + + editName textInput + editKeys []textInput + editVals []textInput + editFocus int + editIsNew bool + editMount vault.Mount + editPath string + editSecret *vault.Secret + editSnap string // serialized snapshot at open, for the unsaved-changes guard + editConfirm bool // pending "discard unsaved changes?" on esc + + versions []vault.VersionMeta + versionsCursor int + + confirm *confirmSpec + profile profileState + helpOpen bool + + tokenInfo *token.Info + quitting bool +} + +func newModel(ctx context.Context, a *cli.App) (*Model, error) { + isDark := theme.IsDark(a.File.Theme, a.Settings.Appearance) + mono := theme.NoColor(a.Settings.NoColor) + styles := theme.Build(a.File.Theme, isDark, mono) + + km := keys.Default() + if err := km.Apply(a.File.Keys); err != nil { + return nil, err + } + if a.Settings.ReadOnly { + km.DisableWrites() + } + + sp := spinner.New(spinner.WithSpinner(spinner.MiniDot)) + hp := help.New() + hp.Styles = styles.HelpStyles() + + m := &Model{ + ctx: ctx, + a: a, + svc: a.Service(), + styles: styles, + keys: km, + help: hp, + spin: sp, + secretMask: map[string]bool{}, + } + return m, nil +} + +func (m *Model) Init() tea.Cmd { + return tea.Batch(m.startAuth(), m.spin.Tick, tickCmd()) +} + +func tickCmd() tea.Cmd { + return tea.Tick(30*time.Second, func(t time.Time) tea.Msg { return tickMsg(t) }) +} + +func (m *Model) top() screen { + if len(m.stack) == 0 { + return scrAuth + } + return m.stack[len(m.stack)-1].scr +} + +func (m *Model) push(f frame) { m.stack = append(m.stack, f) } + +func (m *Model) pop() (frame, bool) { + if len(m.stack) == 0 { + return frame{}, false + } + f := m.stack[len(m.stack)-1] + m.stack = m.stack[:len(m.stack)-1] + return f, true +} + +func (m *Model) currentFrame() frame { + if len(m.stack) == 0 { + return frame{scr: scrMounts} + } + return m.stack[len(m.stack)-1] +} + +// overlayActive reports whether anything should render on top of the +// current screen instead of routing input to it. +func (m *Model) overlayActive() bool { + return m.confirm != nil || m.profile.open || m.helpOpen +} + +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.lay = computeLayout(msg.Width, msg.Height) + m.help.SetWidth(m.lay.w - 2) + m.resizeList() + m.resizeSecretViewport() + return m, nil + + case tea.KeyPressMsg: + if key.Matches(msg, m.keys.ForceQuit) { + m.quitting = true + return m, tea.Quit + } + if m.confirm != nil { + return m.updateConfirm(msg) + } + if m.profile.open { + return m.updateProfilePicker(msg) + } + if m.helpOpen { + return m.updateHelp(msg) + } + if !m.textInputActive() { + if key.Matches(msg, m.keys.Help) { + m.helpOpen = true + return m, nil + } + if key.Matches(msg, m.keys.Profiles) && m.top() != scrAuth && len(m.a.File.Profiles) > 1 { + return m.openProfilePicker() + } + if key.Matches(msg, m.keys.Logout) && m.top() != scrAuth { + return m.confirmLogout() + } + } + + case tickMsg: + cmds := []tea.Cmd{tickCmd()} + if m.a.Client.Token() != "" { + cmds = append(cmds, m.refreshTokenInfo()) + } + return m, tea.Batch(cmds...) + + case tokenInfoMsg: + if msg.err == nil { + m.tokenInfo = msg.info + } + return m, nil + + case toastExpireMsg: + m.clearExpiredToast(msg) + return m, nil + + case logoutDoneMsg: + if msg.err != nil { + return m, m.notify(toastErr, "logout: %v", msg.err) + } + m.a.Client.ClearToken() + m.tokenInfo = nil + return m, tea.Batch(m.notify(toastSuccess, "logged out"), m.startAuth()) + + case errMsg: + m.loading = false + return m, m.notify(toastErr, "%v", msg.err) + + case statusMsg: + return m, m.notify(toastInfo, "%s", msg.text) + + case spinner.TickMsg: + var cmd tea.Cmd + m.spin, cmd = m.spin.Update(msg) + return m, cmd + } + + switch m.top() { + case scrAuth: + return m.updateAuth(msg) + case scrMounts, scrBrowser: + return m.updateBrowser(msg) + case scrSecret: + return m.updateSecret(msg) + case scrEditor: + return m.updateEditor(msg) + case scrVersions: + return m.updateVersions(msg) + } + return m, nil +} + +func (m *Model) View() tea.View { + if m.lay.tooSmall { + v := tea.NewView(m.viewTooSmall()) + v.AltScreen = true + v.WindowTitle = "vault-tui" + return v + } + + var body string + var sh screenHelp + switch m.top() { + case scrAuth: + body, sh = m.viewAuth(), m.keys.AuthHelp() + case scrMounts, scrBrowser: + body, sh = m.viewBrowser(), m.keys.BrowserHelp() + case scrSecret: + body, sh = m.viewSecret(), m.keys.SecretHelp() + case scrEditor: + body, sh = m.viewEditor(), m.keys.EditorHelp() + case scrVersions: + body, sh = m.viewVersions(), m.keys.VersionsHelp() + } + + content := lipgloss.JoinVertical(lipgloss.Left, m.viewHeader(), fit(body, m.lay.w, m.lay.bodyH), m.viewFooter(sh)) + + if m.confirm != nil { + content = m.overlay(content, m.viewConfirm()) + } + if m.profile.open { + content = m.overlay(content, m.viewProfilePicker()) + } + if m.helpOpen { + content = m.overlay(content, m.viewHelp()) + } + + v := tea.NewView(content) + v.AltScreen = true + v.WindowTitle = "vault-tui — " + m.a.Settings.Profile + return v +} + +func (m *Model) refreshTokenInfo() tea.Cmd { + return func() tea.Msg { + info, err := token.Lookup(m.ctx, m.a.Client) + return tokenInfoMsg{info: info, err: err} + } +} diff --git a/internal/ui/msgs.go b/internal/ui/msgs.go new file mode 100644 index 0000000..ad47e7d --- /dev/null +++ b/internal/ui/msgs.go @@ -0,0 +1,90 @@ +package ui + +import ( + "time" + + "git.morlana.online/f.weber/vault-tui/internal/auth" + "git.morlana.online/f.weber/vault-tui/internal/token" + "git.morlana.online/f.weber/vault-tui/internal/vault" +) + +// Every Vault/auth call the TUI makes is issued as a tea.Cmd and reported +// back as one of these typed messages — Update never blocks on I/O itself. + +type mountsMsg struct { + mounts []vault.Mount + err error +} + +type listMsg struct { + listing vault.Listing + mount vault.Mount + path string + err error +} + +type secretMsg struct { + secret *vault.Secret + mount vault.Mount + path string + err error +} + +type versionsMsg struct { + versions []vault.VersionMeta + err error +} + +type writeAckMsg struct { + ack *vault.WriteAck + err error +} + +type deleteAckMsg struct { + ack *vault.DeleteAck + err error +} + +type rollbackAckMsg struct { + ack *vault.WriteAck + err error +} + +type undeleteAckMsg struct { + ack *vault.DeleteAck + err error +} + +type profileSwitchMsg struct { + profile string + err error +} + +type logoutDoneMsg struct{ err error } + +// clipboardClearMsg fires Settings.ClipboardClear after a copy, so a +// secret value doesn't linger in the system clipboard indefinitely. +type clipboardClearMsg struct{ seq int } + +type tokenInfoMsg struct { + info *token.Info + err error +} + +// loginEventMsg/loginDoneMsg drive the OIDC/Okta progress pump: Login runs +// in a goroutine and reports each auth.Event plus a final result, exactly +// mirroring the channel-pump pattern used by internal/cli's drainEvents. +type loginEventMsg auth.Event + +type loginDoneMsg struct { + result *auth.Result + err error + storeWarn string // non-empty if login succeeded but token persistence failed +} + +type errMsg struct{ err error } + +type statusMsg struct{ text string } + +// tickMsg drives the token-TTL countdown in the status bar. +type tickMsg time.Time diff --git a/internal/ui/overlay.go b/internal/ui/overlay.go new file mode 100644 index 0000000..8632857 --- /dev/null +++ b/internal/ui/overlay.go @@ -0,0 +1,43 @@ +package ui + +import "charm.land/lipgloss/v2" + +// overlay composites box on top of base, centered over the current +// terminal size, using lipgloss/v2's real layer compositor. This is what +// makes confirm dialogs, the help screen, and the profile picker float +// over the current screen instead of being appended below it. +func (m *Model) overlay(base, box string) string { + bw, bh := lipgloss.Size(box) + x := (m.lay.w - bw) / 2 + y := (m.lay.h - bh) / 2 + if x < 0 { + x = 0 + } + if y < 0 { + y = 0 + } + return lipgloss.NewCompositor( + lipgloss.NewLayer(base), + lipgloss.NewLayer(box).X(x).Y(y).Z(1), + ).Render() +} + +const ( + modalMaxContentWidth = 64 + modalFrameWidth = 10 // border(2) + padding(4, from Padding(1,2)) + a little breathing room + modalMinContentWidth = 24 +) + +// modalWrapWidth is the text-wrap width a screen should use before handing +// content to styles.Modal/ModalDanger — keeps the box from ever exceeding +// the terminal or the design's max modal width. +func (m *Model) modalWrapWidth() int { + w := m.lay.w - modalFrameWidth + if w > modalMaxContentWidth { + w = modalMaxContentWidth + } + if w < modalMinContentWidth { + w = modalMinContentWidth + } + return w +} diff --git a/internal/ui/run.go b/internal/ui/run.go new file mode 100644 index 0000000..943a9ab --- /dev/null +++ b/internal/ui/run.go @@ -0,0 +1,38 @@ +// Package ui is vault-tui's Bubbletea v2 terminal UI. It talks to Vault +// exclusively through internal/vault.Service and internal/auth.Method — +// never hashicorp/vault/api directly — which is what keeps this package +// swappable/testable independently of the headless CLI in internal/cli +// (which builds the very same Service/Method values from the same +// resolved config.Settings). +package ui + +import ( + "context" + "fmt" + + tea "charm.land/bubbletea/v2" + + "git.morlana.online/f.weber/vault-tui/internal/cli" +) + +// Run launches the TUI. Wired into internal/cli's "ui" command (and its +// DefaultCommand) via cli.SetUIRunner in cmd/vault-tui/main.go, so that +// internal/cli itself never has to import a TUI toolkit. +func Run(ctx context.Context, a *cli.App) error { + m, err := newModel(ctx, a) + if err != nil { + return err + } + p := tea.NewProgram(m, tea.WithContext(ctx)) + _, err = p.Run() + return err +} + +func init() { + cli.SetUIRunner(func(ctx context.Context, a *cli.App) error { + if err := Run(ctx, a); err != nil { + return fmt.Errorf("ui: %w", err) + } + return nil + }) +} diff --git a/internal/ui/screen_auth.go b/internal/ui/screen_auth.go new file mode 100644 index 0000000..c56dc62 --- /dev/null +++ b/internal/ui/screen_auth.go @@ -0,0 +1,394 @@ +package ui + +import ( + "context" + "fmt" + "sort" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "git.morlana.online/f.weber/vault-tui/internal/auth" +) + +// startAuth (re)enters the auth screen: build the method list (prioritising +// the profile's configured default, if any) and reset to the picker phase. +// Called from Init and again whenever a token turns out to be missing or +// invalid (see tokenInfoMsg handling and ensureLoggedIn's own error path). +func (m *Model) startAuth() tea.Cmd { + methods := auth.Default().All() + cursor := 0 + for i, meth := range methods { + if meth.Name() == m.a.Settings.Auth.Method { + cursor = i + } + } + m.auth = authState{phase: 0, methods: methods, cursor: cursor} + m.stack = nil + + return m.tryExistingToken() +} + +// tryExistingToken attempts the flag/env/store token resolution the +// headless CLI also uses (internal/cli.App.EnsureLoggedIn) before falling +// back to an interactive login — this is what makes VAULT_TOKEN and a +// pre-existing `vault login` session "just work" without visiting the auth +// screen at all. +func (m *Model) tryExistingToken() tea.Cmd { + return func() tea.Msg { + _, err := m.a.EnsureLoggedIn(m.ctx, "") + if err != nil { + return statusMsg{text: "no saved token — pick an auth method"} + } + return loginDoneMsg{result: &auth.Result{Token: m.a.Client.Token()}} + } +} + +func (m *Model) updateAuth(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case loginDoneMsg: + if msg.err != nil { + m.auth.err = msg.err + m.auth.phase = 0 + return m, nil + } + if msg.result != nil && msg.result.Token != "" && m.a.Client.Token() == "" { + m.a.Client.SetToken(msg.result.Token) + } + m.push(frame{scr: scrMounts}) + cmd := m.loadMounts() + if msg.storeWarn != "" { + cmd = tea.Batch(cmd, m.notify(toastWarn, "logged in, but could not save token: %s", msg.storeWarn)) + } + return m, cmd + + case loginEventMsg: + switch msg.Kind { + case auth.EventOpenURL: + m.auth.authURL = msg.URL + default: + m.auth.status = append(m.auth.status, msg.Message) + } + return m, waitForAuthEvent(m.auth.events) + } + + switch m.auth.phase { + case 0: + if km, ok := msg.(tea.KeyPressMsg); ok { + return m.updateAuthPicker(km) + } + case 1: + return m.updateAuthForm(msg) + case 2: + if km, ok := msg.(tea.KeyPressMsg); ok { + return m.updateAuthWaiting(km) + } + } + return m, nil +} + +func (m *Model) updateAuthPicker(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, m.keys.Down): + if m.auth.cursor < len(m.auth.methods)-1 { + m.auth.cursor++ + } + case key.Matches(msg, m.keys.Up): + if m.auth.cursor > 0 { + m.auth.cursor-- + } + case key.Matches(msg, m.keys.Enter): + return m.chooseAuthMethod(m.auth.methods[m.auth.cursor]) + case key.Matches(msg, m.keys.Quit), key.Matches(msg, m.keys.ForceQuit): + m.quitting = true + return m, tea.Quit + } + return m, nil +} + +func (m *Model) chooseAuthMethod(meth auth.Method) (tea.Model, tea.Cmd) { + m.auth.method = meth + m.auth.mount = m.a.Settings.Auth.Mount + if m.auth.mount == "" || m.a.Settings.Auth.Method != meth.Name() { + m.auth.mount = meth.DefaultMount() + } + + creds := auth.Prefill(meth, auth.PrefillSource{ConfigArgs: m.a.Settings.Auth.Params}) + if len(auth.Missing(meth, creds)) == 0 { + return m.beginLogin(meth, creds) + } + + m.auth.inputs = nil + for _, f := range meth.Fields() { + ti := m.newTextInput(fieldSpec{label: f.Label, secret: f.Kind == auth.FieldSecret, value: creds.Get(f.Name)}) + m.auth.inputs = append(m.auth.inputs, textInputField{field: f, input: ti}) + } + m.auth.focus = 0 + var cmd tea.Cmd + if len(m.auth.inputs) > 0 { + cmd = m.auth.inputs[0].input.Focus() + } + m.auth.phase = 1 + return m, cmd +} + +// updateAuthForm handles the credential-form phase. It only special-cases +// key presses that are form navigation (esc/tab/enter); everything else — +// including non-key messages like a textinput's own cursor-blink tick — is +// forwarded straight to the focused field's Update. +func (m *Model) updateAuthForm(msg tea.Msg) (tea.Model, tea.Cmd) { + if km, ok := msg.(tea.KeyPressMsg); ok { + switch km.String() { + case "esc": + m.auth.phase = 0 + return m, nil + case "tab", "down": + return m, m.moveAuthFocus(1) + case "shift+tab", "up": + return m, m.moveAuthFocus(-1) + case "enter": + if m.auth.focus < len(m.auth.inputs)-1 { + return m, m.moveAuthFocus(1) + } + creds := auth.Credentials{} + for _, f := range m.auth.inputs { + creds[f.field.Name] = f.input.Value() + } + if err := auth.Validate(m.auth.method, creds); err != nil { + m.auth.err = err + return m, nil + } + return m.beginLogin(m.auth.method, creds) + } + } + if len(m.auth.inputs) == 0 { + return m, nil + } + var cmd tea.Cmd + m.auth.inputs[m.auth.focus].input, cmd = m.auth.inputs[m.auth.focus].input.Update(msg) + return m, cmd +} + +func (m *Model) moveAuthFocus(delta int) tea.Cmd { + if len(m.auth.inputs) == 0 { + return nil + } + m.auth.inputs[m.auth.focus].input.Blur() + m.auth.focus = (m.auth.focus + delta + len(m.auth.inputs)) % len(m.auth.inputs) + return m.auth.inputs[m.auth.focus].input.Focus() +} + +func (m *Model) beginLogin(meth auth.Method, creds auth.Credentials) (tea.Model, tea.Cmd) { + m.auth.phase = 2 + m.auth.err = nil + m.auth.status = nil + m.auth.authURL = "" + + ctx, cancel := context.WithCancel(m.ctx) + m.auth.cancel = cancel + events := make(chan auth.Event, 16) + m.auth.events = events + + req := auth.Request{Mount: m.auth.mount, Namespace: m.a.Settings.Namespace, Creds: creds, Events: events} + run := func() tea.Msg { + defer close(events) + sec, err := meth.Login(ctx, m.a.Client, req) + if err != nil { + return loginDoneMsg{err: err} + } + res, err := auth.NewResult(sec, m.a.Settings.Namespace) + if err != nil { + return loginDoneMsg{err: err} + } + if serr := m.a.Store.Store(ctx, res.Token); serr != nil { + return loginDoneMsg{result: res, storeWarn: serr.Error()} + } + return loginDoneMsg{result: res} + } + return m, tea.Batch(run, waitForAuthEvent(events)) +} + +func waitForAuthEvent(events <-chan auth.Event) tea.Cmd { + return func() tea.Msg { + e, ok := <-events + if !ok { + return nil + } + return loginEventMsg(e) + } +} + +func (m *Model) updateAuthWaiting(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + if m.auth.cancel != nil { + m.auth.cancel() + } + m.auth.phase = 0 + return m, nil + case "y": + return m, copyToClipboard(m.auth.authURL) + } + return m, nil +} + +func copyToClipboard(s string) tea.Cmd { + if s == "" { + return nil + } + return tea.SetClipboard(s) +} + +// authCardWidth is the inner content width every auth-phase card wraps +// text to, capped so the card never dominates a very wide terminal. +func (m *Model) authCardWidth() int { + w := m.lay.bodyW - 12 + if w > 56 { + w = 56 + } + if w < 24 { + w = 24 + } + return w +} + +// authCard centers content (already wrapped to authCardWidth) in a bordered +// card over the auth screen's body area — the "modern app" look asked for, +// replacing the old flush-left, unbounded-width panel. +func (m *Model) authCard(content string) string { + box := m.styles.PanelActive.Render(content) + return lipgloss.Place(m.lay.bodyW, m.lay.bodyH, lipgloss.Center, lipgloss.Center, box, + lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(m.styles.Bg))) +} + +func (m *Model) viewAuth() string { + switch m.auth.phase { + case 1: + return m.viewAuthForm() + case 2: + return m.viewAuthWaiting() + default: + return m.viewAuthPicker() + } +} + +// methodDescription returns a one-line hint for a method: its +// auth.Describable text if implemented, else a small built-in fallback so +// the picker never shows a bare name with nothing else to go on. +func methodDescription(meth auth.Method) string { + if d, ok := meth.(auth.Describable); ok { + return d.Description() + } + switch meth.Name() { + case "token": + return "use an existing Vault token" + case "oidc": + return "sign in via your browser" + case "userpass": + return "username and password" + case "ldap": + return "LDAP directory credentials" + case "okta": + return "Okta username/password, with MFA" + case "radius": + return "RADIUS username/password" + case "approle": + return "role ID and secret ID" + case "github": + return "a GitHub personal access token" + case "jwt": + return "a role and a signed JWT" + case "kubernetes": + return "the pod's projected service-account token" + case "cert": + return "a TLS client certificate" + case "aws", "azure", "gcp": + return "cloud-native machine identity" + default: + return "" + } +} + +func (m *Model) viewAuthPicker() string { + w := m.authCardWidth() + lines := []string{m.styles.ModalTitle.Render("Sign in to Vault"), ""} + for i, meth := range m.auth.methods { + name := meth.DisplayName() + desc := methodDescription(meth) + if i == m.auth.cursor { + lines = append(lines, m.styles.RowSelected.Render(padRight(symChevronR+" "+name, w))) + } else { + line := " " + name + if desc != "" { + line = padRight(line, 18) + m.styles.PanelSubtle.Render(desc) + } + lines = append(lines, line) + } + } + + if unavail := auth.Default().UnavailableAll(); len(unavail) > 0 { + names := make([]string, 0, len(unavail)) + for n := range unavail { + names = append(names, n) + } + sort.Strings(names) + lines = append(lines, "") + for _, n := range names { + lines = append(lines, m.styles.RowDim.Render(fmt.Sprintf(" %s — %s", n, unavail[n]))) + } + } + + if m.auth.err != nil { + lines = append(lines, "", m.styles.ErrorText.Render(lipgloss.Wrap(m.auth.err.Error(), w, ""))) + } + lines = append(lines, "", m.styles.Help.Render("↑/↓ move • enter select • q quit")) + return m.authCard(lipgloss.JoinVertical(lipgloss.Left, lines...)) +} + +func (m *Model) viewAuthForm() string { + w := m.authCardWidth() + lines := []string{m.styles.ModalTitle.Render(m.auth.method.DisplayName()), ""} + for i, f := range m.auth.inputs { + label := f.field.Label + if f.field.Required { + label += " *" + } + if i == m.auth.focus { + label = m.styles.CrumbActive.Render(symChevronR + " " + label) + } else { + label = " " + label + } + f.input.SetWidth(w - 2) + lines = append(lines, label, " "+f.input.View()) + if f.field.Help != "" { + lines = append(lines, m.styles.EmptyState.Render(" "+f.field.Help)) + } + lines = append(lines, "") + } + if m.auth.err != nil { + lines = append(lines, m.styles.ErrorText.Render(lipgloss.Wrap(m.auth.err.Error(), w, "")), "") + } + lines = append(lines, m.styles.Help.Render("tab/shift+tab move • enter next/submit • esc back")) + return m.authCard(lipgloss.JoinVertical(lipgloss.Left, lines...)) +} + +func (m *Model) viewAuthWaiting() string { + w := m.authCardWidth() + lines := []string{m.styles.ModalTitle.Render("Signing in via " + m.auth.method.DisplayName()), ""} + if m.auth.authURL != "" { + lines = append(lines, + "Open this URL if your browser didn't launch automatically:", "", + m.styles.InputFocused.Render(lipgloss.Wrap(m.auth.authURL, w, "")), "", + m.styles.Help.Render("y copy URL • esc cancel")) + } else { + lines = append(lines, m.spin.View()+" waiting…") + } + for _, s := range m.auth.status { + lines = append(lines, m.styles.PanelSubtle.Render(lipgloss.Wrap(s, w, ""))) + } + if m.auth.err != nil { + lines = append(lines, "", m.styles.ErrorText.Render(lipgloss.Wrap(m.auth.err.Error(), w, ""))) + } + return m.authCard(lipgloss.JoinVertical(lipgloss.Left, lines...)) +} diff --git a/internal/ui/screen_browser.go b/internal/ui/screen_browser.go new file mode 100644 index 0000000..646a929 --- /dev/null +++ b/internal/ui/screen_browser.go @@ -0,0 +1,280 @@ +package ui + +import ( + "fmt" + "sort" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/list" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "git.morlana.online/f.weber/vault-tui/internal/vault" +) + +func (m *Model) loadMounts() tea.Cmd { + m.loading = true + return func() tea.Msg { + mounts, err := m.svc.Mounts(m.ctx) + sort.Slice(mounts, func(i, j int) bool { return mounts[i].Path < mounts[j].Path }) + return mountsMsg{mounts: mounts, err: err} + } +} + +func (m *Model) loadListing(mount vault.Mount, path string) tea.Cmd { + m.loading = true + return func() tea.Msg { + l, err := m.svc.KV.List(m.ctx, mount, path) + l.Path = path + return listMsg{listing: l, mount: mount, path: path, err: err} + } +} + +// listPaneOuter is the outer (border-inclusive) size of the panel the list +// renders in: the full body on sizeTiny/sizeCompact, or the left column +// once a side detail panel appears at sizeWide. +func (m *Model) listPaneOuter() (w, h int) { + if m.lay.class == sizeWide { + return m.lay.mainW, m.lay.bodyH + } + return m.lay.bodyW, m.lay.bodyH +} + +// resizeList re-applies the current layout's dimensions to the list — the +// single place list.SetSize is called, so every WindowSizeMsg and every +// list (re)build stays in sync with the same math. +func (m *Model) resizeList() { + if !m.listReady { + return + } + ow, oh := m.listPaneOuter() + iw, ih := panelInner(ow, oh) + ih-- // panel title line + if ih < 3 { + ih = 3 + } + if iw < 10 { + iw = 10 + } + m.list.SetSize(iw, ih) +} + +// setListItems fills the list with items, building it on first use and +// otherwise updating in place — SetItems (not a fresh list.New) is what +// lets a refresh survive with the active filter and, via listSelect, +// the selected row intact. +func (m *Model) setListItems(items []entryItem) tea.Cmd { + litems := make([]list.Item, len(items)) + for i, it := range items { + litems[i] = it + } + + if !m.listReady { + ow, oh := m.listPaneOuter() + iw, ih := panelInner(ow, oh) + ih-- + if ih < 3 { + ih = 3 + } + if iw < 10 { + iw = 10 + } + m.list = list.New(litems, entryDelegate{styles: m.styles}, iw, ih) + m.list.SetShowStatusBar(false) + m.list.SetShowTitle(false) + m.list.SetShowHelp(false) + m.list.DisableQuitKeybindings() + m.list.Styles = m.styles.ListStyles() + m.listReady = true + return nil + } + + cmd := m.list.SetItems(litems) + restored := false + if m.listSelect != "" { + for i, it := range items { + if it.label == m.listSelect { + m.list.Select(i) + restored = true + break + } + } + } + if !restored { + // SetItems doesn't touch the cursor, so switching to a shorter list + // (e.g. drilling into a directory) can otherwise leave it pointing + // past the end, and SelectedItem starts returning nil. + m.list.Select(0) + } + m.resizeList() + return cmd +} + +func (m *Model) updateBrowser(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case mountsMsg: + m.loading = false + if msg.err != nil { + return m, func() tea.Msg { return errMsg{err: msg.err} } + } + cmd := m.setListItems(mountItems(msg.mounts)) + return m, cmd + + case listMsg: + m.loading = false + if msg.err != nil { + return m, func() tea.Msg { return errMsg{err: msg.err} } + } + cmd := m.setListItems(listingItems(msg.mount, msg.listing)) + return m, cmd + + case tea.KeyPressMsg: + // While the list's own filter editor has focus, every keystroke is + // text input for the filter — none of our single-letter global + // bindings (h for Back, l for Enter, ...) may intercept it, or + // typing e.g. "testpath" would pop the screen on its embedded "h". + if !m.listReady || m.list.SettingFilter() { + break + } + switch { + case key.Matches(msg, m.keys.Back): + return m.popBrowser() + case key.Matches(msg, m.keys.Refresh): + return m, m.reloadTop() + case key.Matches(msg, m.keys.Enter): + return m.openSelected() + case key.Matches(msg, m.keys.New): + if !m.a.Settings.ReadOnly && m.top() == scrBrowser { + return m.newSecret() + } + return m, nil + case key.Matches(msg, m.keys.Quit): + m.quitting = true + return m, tea.Quit + } + } + if !m.listReady { + return m, nil + } + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + if it, ok := m.list.SelectedItem().(entryItem); ok { + m.listSelect = it.label + } + return m, cmd +} + +func (m *Model) reloadTop() tea.Cmd { + f := m.currentFrame() + switch f.scr { + case scrMounts: + return m.loadMounts() + case scrBrowser: + return m.loadListing(f.mount, f.path) + } + return nil +} + +func (m *Model) popBrowser() (tea.Model, tea.Cmd) { + if len(m.stack) <= 1 { + return m, nil + } + m.pop() + m.listSelect = "" + return m, m.reloadTop() +} + +func (m *Model) openSelected() (tea.Model, tea.Cmd) { + sel, ok := m.list.SelectedItem().(entryItem) + if !ok { + return m, nil + } + if sel.unsupported { + return m, m.notify(toastWarn, "%s (%s) is not a supported engine", sel.label, sel.mount.Type) + } + m.listSelect = "" + if sel.isMount { + m.push(frame{scr: scrBrowser, mount: sel.mount, path: ""}) + return m, m.loadListing(sel.mount, "") + } + if sel.isDir { + m.push(frame{scr: scrBrowser, mount: sel.mount, path: sel.relPath}) + return m, m.loadListing(sel.mount, sel.relPath) + } + m.push(frame{scr: scrSecret, mount: sel.mount, path: sel.relPath}) + return m, m.loadSecret(sel.mount, sel.relPath, 0) +} + +// detailPreview is the sizeWide side panel's content: whatever we already +// know about the highlighted row, without issuing another Vault read. +func (m *Model) detailPreview() string { + sel, ok := m.list.SelectedItem().(entryItem) + if !ok { + return m.styles.EmptyState.Render("nothing selected") + } + var lines []string + switch { + case sel.isMount: + lines = append(lines, m.styles.PanelTitle.Render(sel.mount.Path)) + lines = append(lines, m.styles.KeyCell.Render("engine")+" "+m.styles.ValueCell.Render(engineLabel(sel.mount))) + if sel.mount.Description != "" { + lines = append(lines, "", sel.mount.Description) + } + if sel.mount.Local { + lines = append(lines, "", m.styles.Badge.Render("local")) + } + if sel.unsupported { + lines = append(lines, "", m.styles.WarnText.Render(symWarn+" unsupported engine: "+sel.mount.Type)) + } + case sel.isDir: + lines = append(lines, m.styles.PanelTitle.Render(sel.mount.Path+sel.relPath)) + lines = append(lines, m.styles.ValueCell.Render("directory — press enter to browse")) + default: + lines = append(lines, m.styles.PanelTitle.Render(sel.mount.Path+sel.relPath)) + lines = append(lines, m.styles.ValueCell.Render("secret — press enter to open")) + } + return lipgloss.JoinVertical(lipgloss.Left, lines...) +} + +func emptyBrowserMessage(f frame, readOnly bool) string { + if f.scr == scrMounts { + return "no mounts visible to this token" + } + if readOnly { + return "nothing here yet" + } + return "nothing here yet — press n to create a secret" +} + +func (m *Model) viewBrowser() string { + f := m.currentFrame() + title := "Mounts" + if f.scr == scrBrowser { + title = f.mount.Path + f.path + } + count := 0 + if m.listReady { + count = len(m.list.Items()) + } + header := m.styles.PanelTitle.Render(title) + " " + m.styles.PanelSubtle.Render(fmt.Sprintf("%d item(s)", count)) + + var body string + switch { + case m.loading || !m.listReady: + body = m.spin.View() + " loading…" + case count == 0: + body = m.styles.EmptyState.Render(emptyBrowserMessage(f, m.a.Settings.ReadOnly)) + default: + body = m.list.View() + } + mainContent := lipgloss.JoinVertical(lipgloss.Left, header, body) + + ow, oh := m.listPaneOuter() + panel := renderPanel(m.styles.PanelActive, ow, oh, mainContent) + + if m.lay.class != sizeWide || !m.listReady || count == 0 { + return panel + } + side := renderPanel(m.styles.SidePanel, m.lay.sideW, m.lay.bodyH, m.detailPreview()) + return lipgloss.JoinHorizontal(lipgloss.Top, panel, " ", side) +} diff --git a/internal/ui/screen_editor.go b/internal/ui/screen_editor.go new file mode 100644 index 0000000..1e3eafe --- /dev/null +++ b/internal/ui/screen_editor.go @@ -0,0 +1,305 @@ +package ui + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "git.morlana.online/f.weber/vault-tui/internal/vault" +) + +// --- editor: create/update a KV secret ----------------------------------- + +func (m *Model) newSecret() (tea.Model, tea.Cmd) { + f := m.currentFrame() + m.editMount, m.editPath, m.editIsNew, m.editSecret = f.mount, f.path, true, nil + m.editName = m.newTextInput(fieldSpec{label: "secret name"}) + cmd := m.editName.Focus() + m.editKeys = []textInput{m.newTextInput(fieldSpec{label: "key"})} + m.editVals = []textInput{m.newTextInput(fieldSpec{label: "value"})} + m.editFocus = -1 // -1 == the name field + m.editSnap = m.editSnapshot() + m.push(frame{scr: scrEditor, mount: f.mount, path: f.path}) + return m, cmd +} + +func (m *Model) editSelected() (tea.Model, tea.Cmd) { + if m.secret == nil { + return m, nil + } + f := m.currentFrame() + m.editMount, m.editPath, m.editIsNew, m.editSecret = f.mount, f.path, false, m.secret + m.editKeys, m.editVals = nil, nil + for _, k := range sortedKeys(m.secret.Data) { + m.editKeys = append(m.editKeys, m.textInputWithValue("key", k)) + m.editVals = append(m.editVals, m.textInputWithValue("value", fmt.Sprint(m.secret.Data[k]))) + } + if len(m.editKeys) == 0 { + m.editKeys = []textInput{m.newTextInput(fieldSpec{label: "key"})} + m.editVals = []textInput{m.newTextInput(fieldSpec{label: "value"})} + } + m.editFocus = 0 + cmd := m.editKeys[0].Focus() + m.editSnap = m.editSnapshot() + m.push(frame{scr: scrEditor, mount: f.mount, path: f.path}) + return m, cmd +} + +// editSnapshot serializes the editor's current field values so esc can +// tell whether anything actually changed before asking to discard. +func (m *Model) editSnapshot() string { + var b strings.Builder + b.WriteString(m.editName.Value()) + for i := range m.editKeys { + b.WriteByte(0) + b.WriteString(m.editKeys[i].Value()) + b.WriteByte('=') + b.WriteString(m.editVals[i].Value()) + } + return b.String() +} + +func (m *Model) updateEditor(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case writeAckMsg: + m.loading = false + if msg.err != nil { + return m, func() tea.Msg { return errMsg{err: msg.err} } + } + toastCmd := m.notify(toastSuccess, "saved (version %d)", msg.ack.Version) + savedPath := msg.ack.Path + m.pop() + if m.top() == scrSecret { + return m, tea.Batch(toastCmd, m.loadSecret(m.editMount, savedPath, 0)) + } + return m, tea.Batch(toastCmd, m.reloadTop()) + + case tea.KeyPressMsg: + switch msg.String() { + case "esc": + if m.editSnapshot() != m.editSnap { + m.confirm = &confirmSpec{ + title: "Discard changes?", + body: "Unsaved edits to this secret will be lost.", + danger: true, + onConfirm: func() tea.Cmd { + m.pop() + return nil + }, + } + return m, nil + } + m.pop() + return m, nil + case "ctrl+a": + m.editKeys = append(m.editKeys, m.newTextInput(fieldSpec{label: "key"})) + m.editVals = append(m.editVals, m.newTextInput(fieldSpec{label: "value"})) + return m, nil + case "ctrl+x": + return m, m.removeCurrentField() + case "ctrl+s": + return m.saveEditor() + case "tab": + return m, m.moveEditFocus(1) + case "shift+tab": + return m, m.moveEditFocus(-1) + } + return m.updateEditorInput(msg) + } + return m.updateEditorInput(msg) +} + +// focus encoding: -1 = name field (new-secret only), 2*i = key[i], 2*i+1 = val[i] +func (m *Model) moveEditFocus(delta int) tea.Cmd { + maxIdx := len(m.editKeys)*2 - 1 + m.blurEdit() + if m.editIsNew { + m.editFocus += delta + if m.editFocus < -1 { + m.editFocus = maxIdx + } else if m.editFocus > maxIdx { + m.editFocus = -1 + } + } else { + if maxIdx < 0 { + return nil + } + m.editFocus = (m.editFocus + delta + maxIdx + 1) % (maxIdx + 1) + } + return m.focusEdit() +} + +func (m *Model) removeCurrentField() tea.Cmd { + if m.editFocus < 0 || len(m.editKeys) == 0 { + return nil + } + i := m.editFocus / 2 + if len(m.editKeys) <= 1 { + m.editKeys[0] = m.newTextInput(fieldSpec{label: "key"}) + m.editVals[0] = m.newTextInput(fieldSpec{label: "value"}) + m.editFocus = 0 + return m.editKeys[0].Focus() + } + m.editKeys = append(m.editKeys[:i], m.editKeys[i+1:]...) + m.editVals = append(m.editVals[:i], m.editVals[i+1:]...) + if m.editFocus >= len(m.editKeys)*2 { + m.editFocus = len(m.editKeys)*2 - 1 + } + return m.focusEdit() +} + +func (m *Model) blurEdit() { + if m.editFocus == -1 { + m.editName.Blur() + return + } + i, isKey := m.editFocus/2, m.editFocus%2 == 0 + if i >= len(m.editKeys) { + return + } + if isKey { + m.editKeys[i].Blur() + } else { + m.editVals[i].Blur() + } +} + +func (m *Model) focusEdit() tea.Cmd { + if m.editFocus == -1 { + return m.editName.Focus() + } + i, isKey := m.editFocus/2, m.editFocus%2 == 0 + if i >= len(m.editKeys) { + return nil + } + if isKey { + return m.editKeys[i].Focus() + } + return m.editVals[i].Focus() +} + +// updateEditorInput forwards msg to whichever field is focused — every +// message type, not just key presses, so a textinput's own async commands +// (cursor blink, paste) actually reach it instead of being dropped by the +// screen dispatch above. +func (m *Model) updateEditorInput(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + if m.editFocus == -1 { + m.editName, cmd = m.editName.Update(msg) + return m, cmd + } + if len(m.editKeys) == 0 { + return m, nil + } + i, isKey := m.editFocus/2, m.editFocus%2 == 0 + if i >= len(m.editKeys) { + return m, nil + } + if isKey { + m.editKeys[i], cmd = m.editKeys[i].Update(msg) + } else { + m.editVals[i], cmd = m.editVals[i].Update(msg) + } + return m, cmd +} + +func (m *Model) saveEditor() (tea.Model, tea.Cmd) { + data := map[string]interface{}{} + for i := range m.editKeys { + k := m.editKeys[i].Value() + if k == "" { + continue + } + data[k] = m.editVals[i].Value() + } + path := m.editPath + if m.editIsNew { + name := m.editName.Value() + if name == "" { + return m, m.notify(toastWarn, "secret name is required") + } + path = m.editPath + name + } + + useCAS := m.a.Settings.RequireCAS && m.editMount.Kind == vault.EngineKVv2 + cas := 0 + if m.editSecret != nil { + cas = m.editSecret.Version + } + + m.loading = true + mount := m.editMount + // Deliberately NOT mutating m.editPath here: on a failed write (CAS + // mismatch, permission denied, ...) the editor screen stays open so the + // user can retry, and a premature mutation would double-concatenate the + // name on a second "new secret" save attempt. writeAckMsg's handler uses + // ack.Path — the path Write actually succeeded at — instead. + return m, func() tea.Msg { + ack, err := m.svc.KV.Write(m.ctx, mount, path, data, useCAS, cas) + return writeAckMsg{ack: ack, err: err} + } +} + +// editFieldWidths splits the panel's inner width into key/value columns — +// side by side from sizeCompact up, stacked on sizeTiny. +func (m *Model) editFieldWidths(iw int) (keyColW, valColW int) { + if m.lay.class == sizeTiny { + return iw, iw + } + keyColW = iw / 3 + if keyColW < 16 { + keyColW = 16 + } + valColW = iw - keyColW - 2 + if valColW < 12 { + valColW = 12 + } + return +} + +func (m *Model) viewEditor() string { + ow, oh := m.lay.bodyW, m.lay.bodyH + iw, _ := panelInner(ow, oh) + tiny := m.lay.class == sizeTiny + keyColW, valColW := m.editFieldWidths(iw) + + rows := []string{m.styles.PanelTitle.Render("Edit " + m.editMount.Path + m.editPath), ""} + + if m.editIsNew { + label := " name" + if m.editFocus == -1 { + label = m.styles.CrumbActive.Render(symChevronR + " name") + } + m.editName.SetWidth(max(iw-2, 8)) + rows = append(rows, label, " "+m.editName.View(), "") + } + + const lbl = 8 // widest label ("▸ value") is 7 cells; +1 keeps a gap before the input's own prompt + for i := range m.editKeys { + kFocused, vFocused := m.editFocus == 2*i, m.editFocus == 2*i+1 + kLabel, vLabel := padRight("key", lbl), padRight("value", lbl) + if kFocused { + kLabel = m.styles.CrumbActive.Render(padRight(symChevronR+" key", lbl)) + } + if vFocused { + vLabel = m.styles.CrumbActive.Render(padRight(symChevronR+" value", lbl)) + } + m.editKeys[i].SetWidth(max(keyColW-lbl-1, 6)) + m.editVals[i].SetWidth(max(valColW-lbl-1, 6)) + + keyCell := kLabel + m.editKeys[i].View() + valCell := vLabel + m.editVals[i].View() + if tiny { + rows = append(rows, keyCell, valCell, "") + } else { + rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, padRight(keyCell, keyColW+lbl+2), valCell)) + } + } + if !tiny { + rows = append(rows, "") + } + + return renderPanel(m.styles.PanelActive, ow, oh, lipgloss.JoinVertical(lipgloss.Left, rows...)) +} diff --git a/internal/ui/screen_help.go b/internal/ui/screen_help.go new file mode 100644 index 0000000..5f9b4f9 --- /dev/null +++ b/internal/ui/screen_help.go @@ -0,0 +1,27 @@ +package ui + +import ( + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +// updateHelp handles the "?" full-keybinding overlay — any key closes it, +// since it's purely informational. +func (m *Model) updateHelp(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + m.helpOpen = false + return m, nil +} + +func (m *Model) viewHelp() string { + w := m.lay.w - 12 + if w > 72 { + w = 72 + } + if w < 30 { + w = 30 + } + m.help.SetWidth(w) + body := m.help.FullHelpView(m.keys.FullHelp()) + lines := []string{m.styles.ModalTitle.Render("Keybindings"), "", body, "", m.styles.Help.Render("press any key to close")} + return m.styles.Modal.Render(lipgloss.JoinVertical(lipgloss.Left, lines...)) +} diff --git a/internal/ui/screen_profile.go b/internal/ui/screen_profile.go new file mode 100644 index 0000000..7348023 --- /dev/null +++ b/internal/ui/screen_profile.go @@ -0,0 +1,116 @@ +package ui + +import ( + "sort" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "git.morlana.online/f.weber/vault-tui/internal/ui/keys" +) + +// textInputActive reports whether a free-text field currently owns the +// keyboard, so global single-letter bindings (help "?", profiles "p", ...) +// don't get eaten while the user is typing a value/filter that happens to +// contain that letter. +func (m *Model) textInputActive() bool { + if m.top() == scrEditor { + return true + } + if m.top() == scrAuth && m.auth.phase == 1 { + return true + } + if (m.top() == scrMounts || m.top() == scrBrowser) && m.listReady && m.list.SettingFilter() { + return true + } + return false +} + +func (m *Model) openProfilePicker() (tea.Model, tea.Cmd) { + names := make([]string, 0, len(m.a.File.Profiles)) + for n := range m.a.File.Profiles { + names = append(names, n) + } + sort.Strings(names) + cursor := 0 + for i, n := range names { + if n == m.a.Settings.Profile { + cursor = i + } + } + m.profile = profileState{open: true, names: names, cursor: cursor} + return m, nil +} + +func (m *Model) updateProfilePicker(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, m.keys.Up): + if m.profile.cursor > 0 { + m.profile.cursor-- + } + case key.Matches(msg, m.keys.Down): + if m.profile.cursor < len(m.profile.names)-1 { + m.profile.cursor++ + } + case key.Matches(msg, m.keys.Enter): + return m.switchToSelectedProfile() + case key.Matches(msg, m.keys.Back), key.Matches(msg, m.keys.Quit): + m.profile.open = false + } + return m, nil +} + +func (m *Model) switchToSelectedProfile() (tea.Model, tea.Cmd) { + name := m.profile.names[m.profile.cursor] + m.profile.open = false + if name == m.a.Settings.Profile { + return m, nil + } + if err := m.a.SwitchProfile(name); err != nil { + return m, m.notify(toastErr, "switch profile: %v", err) + } + + m.svc = m.a.Service() + km := keys.Default() + _ = km.Apply(m.a.File.Keys) // already validated once at startup; config hasn't changed + if m.a.Settings.ReadOnly { + km.DisableWrites() + } + m.keys = km + m.tokenInfo = nil + m.listSelect = "" + return m, m.startAuth() +} + +func (m *Model) confirmLogout() (tea.Model, tea.Cmd) { + profile := m.a.Settings.Profile + action := func() tea.Cmd { + return func() tea.Msg { + return logoutDoneMsg{err: m.a.Store.Erase(m.ctx)} + } + } + m.confirm = &confirmSpec{ + title: "Log out?", + body: "Forgets the saved token for profile " + profile + ". This does not revoke it in Vault.", + onConfirm: action, + } + return m, nil +} + +func (m *Model) viewProfilePicker() string { + lines := []string{m.styles.ModalTitle.Render("Switch profile"), ""} + for i, n := range m.profile.names { + marker := " " + if n == m.a.Settings.Profile { + marker = m.styles.SuccessText.Render(symCurrent) + " " + } + line := marker + n + if i == m.profile.cursor { + line = m.styles.RowSelected.Render(padRight(marker+n, 28)) + } + lines = append(lines, line) + } + lines = append(lines, "", m.styles.Help.Render("↑/↓ move • enter switch • esc cancel")) + return m.styles.Modal.Render(lipgloss.JoinVertical(lipgloss.Left, lines...)) +} diff --git a/internal/ui/screen_secret.go b/internal/ui/screen_secret.go new file mode 100644 index 0000000..235d8a0 --- /dev/null +++ b/internal/ui/screen_secret.go @@ -0,0 +1,294 @@ +package ui + +import ( + "fmt" + "sort" + "strings" + "time" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "git.morlana.online/f.weber/vault-tui/internal/vault" +) + +func (m *Model) loadSecret(mount vault.Mount, path string, version int) tea.Cmd { + m.loading = true + return func() tea.Msg { + sec, err := m.svc.KV.Read(m.ctx, mount, path, version) + return secretMsg{secret: sec, mount: mount, path: path, err: err} + } +} + +func sortedKeys(data map[string]interface{}) []string { + keys := make([]string, 0, len(data)) + for k := range data { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func (m *Model) resizeSecretViewport() { + if m.secret == nil { + return + } + iw, ih := panelInner(m.lay.bodyW, m.lay.bodyH) + ih -= 2 // title + metadata line + if ih < 1 { + ih = 1 + } + m.secretVP.SetWidth(iw) + m.secretVP.SetHeight(ih) +} + +func (m *Model) updateSecret(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case secretMsg: + m.loading = false + if msg.err != nil { + return m, func() tea.Msg { return errMsg{err: msg.err} } + } + if msg.secret == nil { + m.pop() + return m, m.notify(toastWarn, "%s%s: secret not found (already deleted?)", msg.mount.Path, msg.path) + } + m.secret = msg.secret + m.secretKeys = sortedKeys(msg.secret.Data) + m.secretMask = make(map[string]bool, len(m.secretKeys)) + for _, k := range m.secretKeys { + m.secretMask[k] = !m.a.Settings.MaskValues + } + if m.secretCursor >= len(m.secretKeys) { + m.secretCursor = 0 + } + m.resizeSecretViewport() + return m, nil + + case deleteAckMsg: + m.loading = false + if msg.err != nil { + return m, func() tea.Msg { return errMsg{err: msg.err} } + } + m.secret = nil + _, popCmd := m.popBrowser() + return m, tea.Batch(m.notify(toastSuccess, "%v: %s", msg.ack.Op, msg.ack.Path), popCmd) + + case clipboardClearMsg: + if msg.seq == m.clipSeq { + return m, tea.SetClipboard("") + } + return m, nil + + case tea.KeyPressMsg: + switch { + case key.Matches(msg, m.keys.Back): + m.secret = nil + return m.popBrowser() + case key.Matches(msg, m.keys.Quit): + // Safe to bind bare "q" here (unlike the editor/auth-form + // screens): the secret detail screen owns no text input. + m.quitting = true + return m, tea.Quit + case key.Matches(msg, m.keys.Up): + if m.secretCursor > 0 { + m.secretCursor-- + } + return m, nil + case key.Matches(msg, m.keys.Down): + if m.secretCursor < len(m.secretKeys)-1 { + m.secretCursor++ + } + return m, nil + case key.Matches(msg, m.keys.ToggleMask): + m.toggleRowMask() + return m, nil + case key.Matches(msg, m.keys.ToggleMaskAll): + m.toggleAllMask() + return m, nil + case key.Matches(msg, m.keys.Versions): + return m.openVersions() + case key.Matches(msg, m.keys.Edit): + if !m.a.Settings.ReadOnly { + return m.editSelected() + } + case key.Matches(msg, m.keys.Delete): + if !m.a.Settings.ReadOnly { + return m.confirmDelete(false) + } + case key.Matches(msg, m.keys.Destroy): + if !m.a.Settings.ReadOnly { + return m.confirmDelete(true) + } + case key.Matches(msg, m.keys.CopyValue): + return m, m.copySelectedValue() + } + } + return m, nil +} + +func (m *Model) toggleRowMask() { + if len(m.secretKeys) == 0 { + return + } + k := m.secretKeys[m.secretCursor] + m.secretMask[k] = !m.secretMask[k] +} + +func (m *Model) toggleAllMask() { + any := false + for _, k := range m.secretKeys { + if m.secretMask[k] { + any = true + break + } + } + for _, k := range m.secretKeys { + m.secretMask[k] = !any + } +} + +func (m *Model) copySelectedValue() tea.Cmd { + if m.secret == nil || len(m.secretKeys) == 0 { + return nil + } + k := m.secretKeys[m.secretCursor] + v := fmt.Sprint(m.secret.Data[k]) + cmds := []tea.Cmd{tea.SetClipboard(v), m.notify(toastSuccess, "copied %s", k)} + if d := m.a.Settings.ClipboardClear; d > 0 { + m.clipSeq++ + seq := m.clipSeq + cmds = append(cmds, tea.Tick(d, func(time.Time) tea.Msg { return clipboardClearMsg{seq: seq} })) + } + return tea.Batch(cmds...) +} + +// confirmTypedName decides whether a destructive confirmation requires the +// user to type the path back (irreversible actions) or just y/enter. +func confirmTypedName(danger bool, path string) string { + if danger { + return path + } + return "" +} + +func (m *Model) confirmDelete(destroy bool) (tea.Model, tea.Cmd) { + f := m.currentFrame() + mount, path := f.mount, f.path + op := vault.OpSoftDelete + danger := destroy + title := "Delete " + path + "?" + body := "This soft-deletes the current version. It can be undeleted." + var versions []int // nil => current version, for the ops that support that shorthand + if mount.Kind != vault.EngineKVv2 { + op = vault.OpDeleteV1 + danger = true + body = "This permanently deletes the secret. This cannot be undone." + } else if destroy { + op = vault.OpDestroy + title = "Destroy " + path + "?" + body = "This permanently destroys the current version's data. This cannot be undone." + // Unlike soft-delete, Vault's destroy endpoint has no "current + // version" shorthand — it always requires explicit version + // numbers — so the version being viewed must be passed along. + if m.secret != nil { + versions = []int{m.secret.Version} + } + } + + action := func() tea.Cmd { + m.loading = true + return func() tea.Msg { + ack, err := m.svc.KV.Delete(m.ctx, mount, path, op, versions) + return deleteAckMsg{ack: ack, err: err} + } + } + if !m.a.Settings.ConfirmDestructive { + return m, action() + } + m.confirm = &confirmSpec{ + title: title, body: body, danger: danger, + typeToConf: confirmTypedName(danger, path), + onConfirm: action, + } + return m, nil +} + +// secretMetaLine renders the version/status line shown between the title +// and the key/value table. +func (m *Model) secretMetaLine() string { + if m.secret == nil || m.secret.Meta == nil { + return "" + } + meta := m.secret.Meta + parts := []string{fmt.Sprintf("version %d", m.secret.Version), "created " + humanTime(meta.CreatedTime)} + if meta.Destroyed { + parts = append(parts, m.styles.ErrorText.Render(symDestroyed+" destroyed")) + } else if meta.DeletionTime != "" { + parts = append(parts, m.styles.WarnText.Render(symDeleted+" deleted")) + } + return m.styles.PanelSubtle.Render(strings.Join(parts, " • ")) +} + +// secretRows renders the key/value table fed to secretVP, one row per +// sorted key with the selected row highlighted full-width. +func (m *Model) secretRows(width int) string { + if len(m.secretKeys) == 0 { + return m.styles.EmptyState.Render("(no data)") + } + keyW := 0 + for _, k := range m.secretKeys { + if w := lipgloss.Width(k); w > keyW { + keyW = w + } + } + if keyW > width/3 { + keyW = width / 3 + } + if keyW < 4 { + keyW = 4 + } + valW := width - keyW - 2 + if valW < 4 { + valW = 4 + } + + lines := make([]string, len(m.secretKeys)) + for i, k := range m.secretKeys { + v := fmt.Sprint(m.secret.Data[k]) + if !m.secretMask[k] { + v = maskValue(v, m.styles.MaskChar) + } + row := padRight(truncate(k, keyW), keyW) + " " + truncate(v, valW) + if i == m.secretCursor { + lines[i] = m.styles.RowSelected.Render(padRight(row, width)) + } else { + lines[i] = m.styles.KeyCell.Render(padRight(truncate(k, keyW), keyW)) + " " + m.styles.ValueCell.Render(truncate(v, valW)) + } + } + return strings.Join(lines, "\n") +} + +func (m *Model) viewSecret() string { + f := m.currentFrame() + ow, oh := m.lay.bodyW, m.lay.bodyH + title := m.styles.PanelTitle.Render(f.mount.Path + f.path) + + if m.loading || m.secret == nil { + body := lipgloss.JoinVertical(lipgloss.Left, title, m.spin.View()+" loading…") + return renderPanel(m.styles.PanelActive, ow, oh, body) + } + + iw, _ := panelInner(ow, oh) + m.resizeSecretViewport() + m.secretVP.SetContent(m.secretRows(iw)) + m.secretVP.EnsureVisible(m.secretCursor, 0, 0) + + lines := []string{title} + if meta := m.secretMetaLine(); meta != "" { + lines = append(lines, meta) + } + lines = append(lines, m.secretVP.View()) + return renderPanel(m.styles.PanelActive, ow, oh, lipgloss.JoinVertical(lipgloss.Left, lines...)) +} diff --git a/internal/ui/screen_versions.go b/internal/ui/screen_versions.go new file mode 100644 index 0000000..299b4d3 --- /dev/null +++ b/internal/ui/screen_versions.go @@ -0,0 +1,222 @@ +package ui + +import ( + "fmt" + "strconv" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "git.morlana.online/f.weber/vault-tui/internal/vault" +) + +func (m *Model) loadVersions(mount vault.Mount, path string) tea.Cmd { + m.loading = true + return func() tea.Msg { + vs, err := m.svc.KV.Versions(m.ctx, mount, path) + return versionsMsg{versions: vs, err: err} + } +} + +func (m *Model) openVersions() (tea.Model, tea.Cmd) { + f := m.currentFrame() + if f.mount.Kind != vault.EngineKVv2 { + return m, m.notify(toastWarn, "version history is only available for KV v2") + } + m.versionsCursor = 0 + m.push(frame{scr: scrVersions, mount: f.mount, path: f.path}) + return m, m.loadVersions(f.mount, f.path) +} + +func (m *Model) updateVersions(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case versionsMsg: + m.loading = false + if msg.err != nil { + return m, func() tea.Msg { return errMsg{err: msg.err} } + } + m.versions = msg.versions + if m.versionsCursor >= len(m.versions) { + m.versionsCursor = 0 + } + return m, nil + + case rollbackAckMsg: + m.loading = false + if msg.err != nil { + return m, func() tea.Msg { return errMsg{err: msg.err} } + } + f := m.currentFrame() + return m, tea.Batch( + m.notify(toastSuccess, "rolled back — created version %d", msg.ack.Version), + m.loadVersions(f.mount, f.path), + ) + + case undeleteAckMsg: + m.loading = false + if msg.err != nil { + return m, func() tea.Msg { return errMsg{err: msg.err} } + } + f := m.currentFrame() + return m, tea.Batch(m.notify(toastSuccess, "undeleted"), m.loadVersions(f.mount, f.path)) + + case tea.KeyPressMsg: + switch { + case key.Matches(msg, m.keys.Back): + m.pop() + return m, m.reloadSecretAfterVersions() + case key.Matches(msg, m.keys.Quit): + m.quitting = true + return m, tea.Quit + case key.Matches(msg, m.keys.Up): + if m.versionsCursor > 0 { + m.versionsCursor-- + } + case key.Matches(msg, m.keys.Down): + if m.versionsCursor < len(m.versions)-1 { + m.versionsCursor++ + } + case key.Matches(msg, m.keys.Enter): + return m.viewSelectedVersion() + case key.Matches(msg, m.keys.Rollback): + if !m.a.Settings.ReadOnly { + return m.confirmRollback() + } + case key.Matches(msg, m.keys.Undelete): + if !m.a.Settings.ReadOnly { + return m.confirmUndelete() + } + } + } + return m, nil +} + +func (m *Model) reloadSecretAfterVersions() tea.Cmd { + f := m.currentFrame() + if f.scr == scrSecret { + return m.loadSecret(f.mount, f.path, 0) + } + return nil +} + +func (m *Model) viewSelectedVersion() (tea.Model, tea.Cmd) { + if len(m.versions) == 0 { + return m, nil + } + v := m.versions[m.versionsCursor] + f := m.currentFrame() + m.push(frame{scr: scrSecret, mount: f.mount, path: f.path}) + return m, m.loadSecret(f.mount, f.path, v.Version) +} + +func (m *Model) confirmRollback() (tea.Model, tea.Cmd) { + if len(m.versions) == 0 { + return m, nil + } + v := m.versions[m.versionsCursor] + f := m.currentFrame() + mount, path := f.mount, f.path + action := func() tea.Cmd { + m.loading = true + return func() tea.Msg { + ack, err := m.svc.KV.Rollback(m.ctx, mount, path, v.Version) + return rollbackAckMsg{ack: ack, err: err} + } + } + if !m.a.Settings.ConfirmDestructive { + return m, action() + } + m.confirm = &confirmSpec{ + title: fmt.Sprintf("Roll back to version %d?", v.Version), + body: "Creates a new version with version " + strconv.Itoa(v.Version) + "'s data. No existing version is deleted.", + onConfirm: action, + } + return m, nil +} + +func (m *Model) confirmUndelete() (tea.Model, tea.Cmd) { + if len(m.versions) == 0 { + return m, nil + } + v := m.versions[m.versionsCursor] + if v.DeletionTime == "" && !v.Destroyed { + return m, m.notify(toastInfo, "version %d is not deleted", v.Version) + } + if v.Destroyed { + return m, m.notify(toastWarn, "version %d was destroyed — it cannot be undeleted", v.Version) + } + f := m.currentFrame() + mount, path := f.mount, f.path + action := func() tea.Cmd { + m.loading = true + return func() tea.Msg { + ack, err := m.svc.KV.Delete(m.ctx, mount, path, vault.OpUndelete, []int{v.Version}) + return undeleteAckMsg{ack: ack, err: err} + } + } + if !m.a.Settings.ConfirmDestructive { + return m, action() + } + m.confirm = &confirmSpec{ + title: fmt.Sprintf("Undelete version %d?", v.Version), + body: "Restores this version so it can be read again.", + onConfirm: action, + } + return m, nil +} + +func (m *Model) viewVersions() string { + f := m.currentFrame() + ow, oh := m.lay.bodyW, m.lay.bodyH + iw, ih := panelInner(ow, oh) + title := m.styles.PanelTitle.Render("Versions of " + f.mount.Path + f.path) + + if m.loading { + return renderPanel(m.styles.PanelActive, ow, oh, lipgloss.JoinVertical(lipgloss.Left, title, m.spin.View()+" loading…")) + } + if len(m.versions) == 0 { + return renderPanel(m.styles.PanelActive, ow, oh, + lipgloss.JoinVertical(lipgloss.Left, title, "", m.styles.EmptyState.Render("no version history"))) + } + + header := m.styles.TableHeader.Render(padRight("", 3) + padRight("version", 10) + padRight("created", 22) + "status") + rowsH := ih - 2 // title + header + if rowsH < 1 { + rowsH = 1 + } + top := m.versionsCursor - rowsH/2 + if top < 0 { + top = 0 + } + if top > len(m.versions)-rowsH { + top = len(m.versions) - rowsH + } + if top < 0 { + top = 0 + } + bottom := min(top+rowsH, len(m.versions)) + + rows := make([]string, 0, bottom-top) + for i := top; i < bottom; i++ { + v := m.versions[i] + state := m.styles.SuccessText.Render(symOK + " active") + if v.Destroyed { + state = m.styles.ErrorText.Render(symDestroyed + " destroyed") + } else if v.DeletionTime != "" { + state = m.styles.WarnText.Render(symDeleted + " deleted") + } + marker := " " + if i == 0 { + marker = m.styles.SuccessText.Render(symCurrent) + " " + } + line := marker + padRight(fmt.Sprintf("v%d", v.Version), 9) + padRight(humanTime(v.CreatedTime), 22) + state + if i == m.versionsCursor { + line = m.styles.RowSelected.Render(padRight(line, iw)) + } + rows = append(rows, line) + } + + body := lipgloss.JoinVertical(lipgloss.Left, title, header, lipgloss.JoinVertical(lipgloss.Left, rows...)) + return renderPanel(m.styles.PanelActive, ow, oh, body) +} diff --git a/internal/ui/textinput.go b/internal/ui/textinput.go new file mode 100644 index 0000000..8bd0c03 --- /dev/null +++ b/internal/ui/textinput.go @@ -0,0 +1,35 @@ +package ui + +import "charm.land/bubbles/v2/textinput" + +// textInput is a local alias purely to keep the many field declarations in +// model.go from repeating the full package-qualified name. +type textInput = textinput.Model + +// newTextInput is a method (not a free function) so every input picks up +// the current theme's colors via styles.TextInputStyles — otherwise inputs +// would render in bubbles' hard-coded default palette regardless of the +// user's theme/appearance settings. +func (m *Model) newTextInput(f fieldSpec) textInput { + ti := textinput.New() + ti.Placeholder = f.label + ti.SetStyles(m.styles.TextInputStyles()) + if f.secret { + ti.EchoMode = textinput.EchoPassword + ti.EchoCharacter = '•' + } + ti.SetValue(f.value) + return ti +} + +func (m *Model) textInputWithValue(label, val string) textInput { + return m.newTextInput(fieldSpec{label: label, value: val}) +} + +// fieldSpec is the minimal shape newTextInput needs; kept separate from +// auth.Field so this file has no dependency on the auth package. +type fieldSpec struct { + label string + secret bool + value string +} diff --git a/internal/ui/theme/theme.go b/internal/ui/theme/theme.go new file mode 100644 index 0000000..4941f6d --- /dev/null +++ b/internal/ui/theme/theme.go @@ -0,0 +1,328 @@ +// Package theme turns config.Theme (the YAML-facing schema) into concrete +// lipgloss.Style values the TUI renders with. +package theme + +import ( + "image/color" + "os" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/list" + "charm.land/bubbles/v2/textinput" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/term" + + "git.morlana.online/f.weber/vault-tui/internal/config" +) + +// Styles is the materialised set of lipgloss styles and colors the whole +// TUI renders with. Built once at startup from config.Theme plus a +// light/dark decision, and passed down by value (styles are cheap, +// immutable value types) to every screen. +type Styles struct { + // Raw colors, exposed for the few call sites that need to compose a + // style lipgloss.Style alone can't express (e.g. list.Styles). + Bg color.Color + Surface color.Color + SurfaceRaised color.Color + Overlay color.Color + Text color.Color + TextMuted color.Color + TextFaint color.Color + TextInverted color.Color + Border color.Color + BorderSubtle color.Color + BorderActive color.Color + Header color.Color + Selection color.Color + SelectionBg color.Color + Accent color.Color + Error color.Color + Warning color.Color + Success color.Color + Info color.Color + Muted color.Color + Masked color.Color + Directory color.Color + Leaf color.Color + + MaskChar string + Mono bool // true when colors are suppressed (--no-color / NO_COLOR) + + // Chrome + HeaderBar lipgloss.Style + AppBadge lipgloss.Style + Crumb lipgloss.Style + CrumbSep lipgloss.Style + CrumbActive lipgloss.Style + Pill lipgloss.Style + PillOK lipgloss.Style + PillWarn lipgloss.Style + PillDanger lipgloss.Style + Footer lipgloss.Style + StatusBar lipgloss.Style + ReadOnly lipgloss.Style + + // Panels + Panel lipgloss.Style + PanelActive lipgloss.Style + PanelTitle lipgloss.Style + PanelSubtle lipgloss.Style + SidePanel lipgloss.Style + EmptyState lipgloss.Style + + // Rows / tables + Row lipgloss.Style + RowSelected lipgloss.Style + RowDim lipgloss.Style + Badge lipgloss.Style + TableHeader lipgloss.Style + KeyCell lipgloss.Style + ValueCell lipgloss.Style + + // Text roles + Title lipgloss.Style + Help lipgloss.Style + ErrorText lipgloss.Style + WarnText lipgloss.Style + SuccessText lipgloss.Style + MaskedText lipgloss.Style + DirText lipgloss.Style + LeafText lipgloss.Style + Selected lipgloss.Style + + // Overlays + Modal lipgloss.Style + ModalDanger lipgloss.Style + ModalTitle lipgloss.Style + Backdrop lipgloss.Style + + // Toasts + ToastInfo lipgloss.Style + ToastSuccess lipgloss.Style + ToastWarn lipgloss.Style + ToastError lipgloss.Style + + // Inputs + InputFocused lipgloss.Style + InputBlurred lipgloss.Style + + isDark bool +} + +// defaultPalette is the built-in fallback: an adaptive light/dark set for +// every named color config.Theme.Colors can override. +func defaultPalette(dark bool) map[string]string { + if dark { + return map[string]string{ + "bg": "#0E1015", "surface": "#151821", "surface_raised": "#1C2130", "overlay": "#20263A", + "text": "#E4E7F1", "text_muted": "#9AA2B8", "text_faint": "#5B6272", "text_inverted": "#0E1015", + "border": "#2A3040", "border_subtle": "#1C2130", "border_active": "#A78BFA", "header": "#9BA3C4", + "selection": "#0E1015", "selection_bg": "#A78BFA", "accent": "#A78BFA", + "error": "#F87171", "warning": "#FBBF24", "success": "#4ADE80", "info": "#6EC1FF", + "muted": "#9AA2B8", "masked": "#5B6272", "directory": "#6EC1FF", "leaf": "#E4E7F1", + } + } + return map[string]string{ + "bg": "#FFFFFF", "surface": "#F6F7FB", "surface_raised": "#EDEFF7", "overlay": "#E3E6F3", + "text": "#1F2430", "text_muted": "#6C7086", "text_faint": "#9AA0AE", "text_inverted": "#FFFFFF", + "border": "#C9CCD6", "border_subtle": "#DEE1EA", "border_active": "#5B34D6", "header": "#3A3F58", + "selection": "#FFFFFF", "selection_bg": "#5B34D6", "accent": "#5B34D6", + "error": "#B3261E", "warning": "#8A6100", "success": "#1B6B3A", "info": "#0B5FA5", + "muted": "#6C7086", "masked": "#9AA0AE", "directory": "#0B5FA5", "leaf": "#1F2430", + } +} + +// IsDark decides light vs. dark once at startup, synchronously — a +// deliberate simplification versus wiring bubbletea's async +// BackgroundColorMsg round trip, since the terminal's background rarely +// changes mid-session. cfg.Appearance can force it. +func IsDark(cfg config.Theme, appearance string) bool { + switch appearance { + case "dark": + return true + case "light": + return false + default: + return lipgloss.HasDarkBackground(term.File(os.Stdin), term.File(os.Stdout)) + } +} + +// NoColor reports whether ANSI color should be suppressed: the NO_COLOR +// convention (https://no-color.org) or an explicit request. +func NoColor(explicit bool) bool { + if explicit { + return true + } + _, set := os.LookupEnv("NO_COLOR") + return set +} + +func colorOf(cfg config.Theme, defaults map[string]string, dark bool, key string) color.Color { + if c, ok := cfg.Colors[key]; ok { + if dark && c.Dark != "" { + return lipgloss.Color(c.Dark) + } + if !dark && c.Light != "" { + return lipgloss.Color(c.Light) + } + } + return lipgloss.Color(defaults[key]) +} + +// Build materialises Styles from cfg and the light/dark decision. When mono +// is true (NO_COLOR / --no-color), every color collapses to the terminal's +// default foreground and hierarchy is carried by weight/reverse instead. +func Build(cfg config.Theme, dark bool, mono bool) *Styles { + def := defaultPalette(dark) + c := func(key string) color.Color { return colorOf(cfg, def, dark, key) } + + s := &Styles{isDark: dark, Mono: mono} + s.Bg, s.Surface, s.SurfaceRaised, s.Overlay = c("bg"), c("surface"), c("surface_raised"), c("overlay") + s.Text, s.TextMuted, s.TextFaint, s.TextInverted = c("text"), c("text_muted"), c("text_faint"), c("text_inverted") + s.Border, s.BorderSubtle, s.BorderActive, s.Header = c("border"), c("border_subtle"), c("border_active"), c("header") + s.Selection, s.SelectionBg, s.Accent = c("selection"), c("selection_bg"), c("accent") + s.Error, s.Warning, s.Success, s.Info = c("error"), c("warning"), c("success"), c("info") + s.Muted, s.Masked, s.Directory, s.Leaf = c("muted"), c("masked"), c("directory"), c("leaf") + + s.MaskChar = cfg.MaskChar + if s.MaskChar == "" { + s.MaskChar = "•" + } + + if mono { + s.flatten() + } + + border := borderFor(cfg.BorderStyle) + pillFG := s.TextInverted + + s.HeaderBar = lipgloss.NewStyle().Foreground(s.Text).Background(s.Surface).Padding(0, 1) + s.AppBadge = lipgloss.NewStyle().Foreground(pillFG).Background(s.Accent).Bold(true).Padding(0, 1) + s.Crumb = lipgloss.NewStyle().Foreground(s.TextMuted).Background(s.Surface) + s.CrumbSep = lipgloss.NewStyle().Foreground(s.TextFaint).Background(s.Surface) + s.CrumbActive = lipgloss.NewStyle().Foreground(s.Text).Background(s.Surface).Bold(true) + s.Pill = lipgloss.NewStyle().Foreground(s.Text).Background(s.SurfaceRaised).Padding(0, 1) + s.PillOK = s.Pill.Foreground(pillFG).Background(s.Success) + s.PillWarn = s.Pill.Foreground(pillFG).Background(s.Warning) + s.PillDanger = s.Pill.Foreground(pillFG).Background(s.Error) + s.Footer = lipgloss.NewStyle().Foreground(s.TextMuted).Background(s.Surface).Padding(0, 1) + s.StatusBar = lipgloss.NewStyle().Foreground(s.TextMuted).Background(s.Surface) + s.ReadOnly = s.PillWarn.Bold(true) + + s.Panel = lipgloss.NewStyle().Border(border).BorderForeground(s.Border).Padding(0, 1) + s.PanelActive = s.Panel.BorderForeground(s.BorderActive) + s.PanelTitle = lipgloss.NewStyle().Foreground(s.Accent).Bold(true) + s.PanelSubtle = lipgloss.NewStyle().Foreground(s.TextMuted) + s.SidePanel = lipgloss.NewStyle().Border(border).BorderForeground(s.BorderSubtle).Padding(0, 1) + s.EmptyState = lipgloss.NewStyle().Foreground(s.TextFaint).Italic(true) + + s.Row = lipgloss.NewStyle().Foreground(s.Text) + s.RowSelected = lipgloss.NewStyle().Foreground(s.Selection).Background(s.SelectionBg).Bold(true) + s.RowDim = lipgloss.NewStyle().Foreground(s.TextFaint) + s.Badge = lipgloss.NewStyle().Foreground(s.TextMuted).Background(s.SurfaceRaised).Padding(0, 1) + s.TableHeader = lipgloss.NewStyle().Foreground(s.TextMuted).Bold(true) + s.KeyCell = lipgloss.NewStyle().Foreground(s.Text).Bold(true) + s.ValueCell = lipgloss.NewStyle().Foreground(s.TextMuted) + + s.Title = lipgloss.NewStyle().Foreground(s.Accent).Bold(true) + s.Help = lipgloss.NewStyle().Foreground(s.TextMuted) + s.ErrorText = lipgloss.NewStyle().Foreground(s.Error).Bold(true) + s.WarnText = lipgloss.NewStyle().Foreground(s.Warning) + s.SuccessText = lipgloss.NewStyle().Foreground(s.Success) + s.MaskedText = lipgloss.NewStyle().Foreground(s.Masked) + s.DirText = lipgloss.NewStyle().Foreground(s.Directory) + s.LeafText = lipgloss.NewStyle().Foreground(s.Leaf) + s.Selected = s.RowSelected + + s.Modal = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(s.Accent). + Background(s.Overlay).Foreground(s.Text).Padding(1, 2) + s.ModalDanger = s.Modal.BorderForeground(s.Error) + s.ModalTitle = lipgloss.NewStyle().Foreground(s.Accent).Bold(true) + s.Backdrop = lipgloss.NewStyle().Foreground(s.TextFaint) + + s.ToastInfo = lipgloss.NewStyle().Foreground(pillFG).Background(s.Info).Padding(0, 1) + s.ToastSuccess = lipgloss.NewStyle().Foreground(pillFG).Background(s.Success).Padding(0, 1) + s.ToastWarn = lipgloss.NewStyle().Foreground(pillFG).Background(s.Warning).Padding(0, 1) + s.ToastError = lipgloss.NewStyle().Foreground(pillFG).Background(s.Error).Padding(0, 1) + + s.InputFocused = lipgloss.NewStyle().Foreground(s.Accent) + s.InputBlurred = lipgloss.NewStyle().Foreground(s.TextMuted) + + return s +} + +// flatten drops every color to the terminal's default foreground (mono +// mode). Called before styles are built so every lipgloss.Style below +// inherits plain colors and relies on Bold/Italic/Reverse for hierarchy. +func (s *Styles) flatten() { + none := lipgloss.Color("") + *s = Styles{ + isDark: s.isDark, Mono: true, MaskChar: s.MaskChar, + Bg: none, Surface: none, SurfaceRaised: none, Overlay: none, + Text: none, TextMuted: none, TextFaint: none, TextInverted: none, + Border: none, BorderSubtle: none, BorderActive: none, Header: none, + Selection: none, SelectionBg: none, Accent: none, + Error: none, Warning: none, Success: none, Info: none, + Muted: none, Masked: none, Directory: none, Leaf: none, + } +} + +func borderFor(style string) lipgloss.Border { + switch style { + case "normal": + return lipgloss.NormalBorder() + case "thick": + return lipgloss.ThickBorder() + case "double": + return lipgloss.DoubleBorder() + case "hidden": + return lipgloss.HiddenBorder() + case "ascii": + return lipgloss.Border{Top: "-", Bottom: "-", Left: "|", Right: "|", TopLeft: "+", TopRight: "+", BottomLeft: "+", BottomRight: "+"} + default: + return lipgloss.RoundedBorder() + } +} + +// HelpStyles adapts bubbles/help's palette to the theme. +func (s *Styles) HelpStyles() help.Styles { + hs := help.DefaultStyles(s.isDark) + hs.ShortKey = lipgloss.NewStyle().Foreground(s.Accent) + hs.ShortDesc = lipgloss.NewStyle().Foreground(s.TextMuted) + hs.ShortSeparator = lipgloss.NewStyle().Foreground(s.TextFaint) + hs.FullKey = hs.ShortKey + hs.FullDesc = hs.ShortDesc + hs.FullSeparator = hs.ShortSeparator + hs.Ellipsis = lipgloss.NewStyle().Foreground(s.TextFaint) + return hs +} + +// TextInputStyles adapts bubbles/textinput's palette to the theme. +func (s *Styles) TextInputStyles() textinput.Styles { + ti := textinput.DefaultStyles(s.isDark) + ti.Focused.Text = lipgloss.NewStyle().Foreground(s.Text) + ti.Focused.Prompt = lipgloss.NewStyle().Foreground(s.Accent) + ti.Focused.Placeholder = lipgloss.NewStyle().Foreground(s.TextFaint) + ti.Blurred.Text = lipgloss.NewStyle().Foreground(s.TextMuted) + ti.Blurred.Prompt = lipgloss.NewStyle().Foreground(s.TextFaint) + ti.Blurred.Placeholder = lipgloss.NewStyle().Foreground(s.TextFaint) + ti.Cursor.Color = s.Accent + return ti +} + +// ListStyles adapts bubbles/list's chrome (filter prompt/cursor, "no +// items", help line) to the theme. The list's per-row rendering is handled +// entirely by our own list.ItemDelegate, not by this. +func (s *Styles) ListStyles() list.Styles { + ls := list.DefaultStyles(s.isDark) + ls.Filter = s.TextInputStyles() + ls.NoItems = s.EmptyState + ls.StatusEmpty = s.EmptyState + ls.HelpStyle = s.Help + ls.StatusBarActiveFilter = lipgloss.NewStyle().Foreground(s.Text) + ls.StatusBarFilterCount = lipgloss.NewStyle().Foreground(s.TextFaint) + ls.PaginationStyle = lipgloss.NewStyle().Foreground(s.TextFaint) + ls.DefaultFilterCharacterMatch = lipgloss.NewStyle().Foreground(s.Accent).Underline(true) + return ls +} diff --git a/internal/ui/toast.go b/internal/ui/toast.go new file mode 100644 index 0000000..bce83a6 --- /dev/null +++ b/internal/ui/toast.go @@ -0,0 +1,68 @@ +package ui + +import ( + "fmt" + "time" + + tea "charm.land/bubbletea/v2" +) + +// toastKind selects which of the theme's toast styles a notification uses. +type toastKind int + +const ( + toastInfo toastKind = iota + toastSuccess + toastWarn + toastErr +) + +// toast is a transient status/error message shown in the footer. Replaces +// the old Model.statusText/errText, which never expired once set. +type toast struct { + kind toastKind + text string + seq int +} + +// toastExpireMsg clears the toast identified by seq — guarded so a +// newer toast issued while an older one's timer is still running can't be +// clobbered by the older timer firing after it. +type toastExpireMsg struct{ seq int } + +// notify replaces the current toast and returns the tea.Cmd that expires +// it. Errors linger noticeably longer than routine status updates. +func (m *Model) notify(kind toastKind, format string, args ...any) tea.Cmd { + m.toastSeq++ + seq := m.toastSeq + m.toast = &toast{kind: kind, text: fmt.Sprintf(format, args...), seq: seq} + + d := 4 * time.Second + if kind == toastErr { + d = 8 * time.Second + } + return tea.Tick(d, func(time.Time) tea.Msg { return toastExpireMsg{seq: seq} }) +} + +func (m *Model) clearExpiredToast(msg toastExpireMsg) { + if m.toast != nil && m.toast.seq == msg.seq { + m.toast = nil + } +} + +// toastView renders the current toast, or "" if there is none. +func (m *Model) toastView() string { + if m.toast == nil { + return "" + } + switch m.toast.kind { + case toastSuccess: + return m.styles.ToastSuccess.Render(symOK + " " + m.toast.text) + case toastWarn: + return m.styles.ToastWarn.Render(symWarn + " " + m.toast.text) + case toastErr: + return m.styles.ToastError.Render(symErr + " " + m.toast.text) + default: + return m.styles.ToastInfo.Render(m.toast.text) + } +} diff --git a/internal/vault/client.go b/internal/vault/client.go new file mode 100644 index 0000000..eb11fb0 --- /dev/null +++ b/internal/vault/client.go @@ -0,0 +1,76 @@ +// Package vault wraps github.com/hashicorp/vault/api behind a small +// Service that the UI and CLI layers talk to. Nothing in this package +// imports a TUI toolkit, so it is fully unit-testable with httptest and +// reusable by the headless commands in internal/cli. +package vault + +import ( + "fmt" + + "github.com/hashicorp/vault/api" + + "git.morlana.online/f.weber/vault-tui/internal/config" +) + +// NewClient builds an *api.Client from a fully resolved config.Settings. +// +// api.DefaultConfig() already calls ReadEnvironment(), and api.NewClient +// additionally picks up VAULT_TOKEN/VAULT_NAMESPACE/VAULT_HEADERS as soon as +// it sees a *api.Config — all of which would bypass the flag>env>profile> +// defaults precedence that config.Resolve already computed into s. So this +// function overwrites every field env may have set, then explicitly clears +// the token and re-sets the namespace from s: ClearToken()+SetNamespace() +// right after NewClient is the load-bearing pair of calls here. +func NewClient(s *config.Settings) (*api.Client, error) { + cfg := api.DefaultConfig() + if cfg.Error != nil { + return nil, fmt.Errorf("building base client config: %w", cfg.Error) + } + + cfg.Address = s.Address + cfg.Timeout = s.Timeout + cfg.MaxRetries = s.MaxRetries + if s.MinRetryWait > 0 { + cfg.MinRetryWait = s.MinRetryWait + } + if s.MaxRetryWait > 0 { + cfg.MaxRetryWait = s.MaxRetryWait + } + cfg.SRVLookup = s.SRVLookup + cfg.DisableRedirects = s.DisableRedirects + cfg.CloneHeaders = true + + tls := &api.TLSConfig{ + CACert: s.CACert, + CACertBytes: s.CACertPEM, + CAPath: s.CAPath, + ClientCert: s.ClientCert, + ClientKey: s.ClientKey, + TLSServerName: s.ServerName, + Insecure: s.SkipVerify, + } + if err := cfg.ConfigureTLS(tls); err != nil { + return nil, fmt.Errorf("configuring TLS: %w", err) + } + + c, err := api.NewClient(cfg) + if err != nil { + return nil, fmt.Errorf("creating vault client: %w", err) + } + + c.ClearToken() + c.SetNamespace(s.Namespace) + c.SetCloneHeaders(true) + if len(s.Headers) > 0 { + h := c.Headers() + if h == nil { + h = make(map[string][]string) + } + for k, v := range s.Headers { + h.Set(k, v) + } + c.SetHeaders(h) + } + + return c, nil +} diff --git a/internal/vault/errors.go b/internal/vault/errors.go new file mode 100644 index 0000000..b1b1ea9 --- /dev/null +++ b/internal/vault/errors.go @@ -0,0 +1,77 @@ +package vault + +import ( + "context" + "errors" + "net" + "strings" + + "github.com/hashicorp/vault/api" +) + +// ErrKind classifies a Vault API error into a small, UI-actionable set. +// See internal/ui's error surfacing tiers: Cancelled is dropped silently, +// NotFound/Forbidden/CAS become a toast plus an inline hint, Unauthorized +// sends the user back to the auth screen, Sealed/Network get a full-body +// retry panel. +type ErrKind uint8 + +const ( + ErrUnknown ErrKind = iota + ErrForbidden + ErrNotFound + ErrUnauthorized + ErrSealed + ErrCAS + ErrNetwork + ErrCancelled +) + +// Classify inspects err (typically returned from a Logical() call) and +// returns its kind plus a short human-readable message. +func Classify(err error) (ErrKind, string) { + if err == nil { + return ErrUnknown, "" + } + if errors.Is(err, context.Canceled) { + return ErrCancelled, "cancelled" + } + + var respErr *api.ResponseError + if errors.As(err, &respErr) { + msg := strings.Join(respErr.Errors, "; ") + if msg == "" { + msg = err.Error() + } + switch respErr.StatusCode { + case 403: + if isTokenInvalid(msg) { + return ErrUnauthorized, "token is invalid or expired" + } + return ErrForbidden, "permission denied" + case 404: + return ErrNotFound, "not found" + case 400: + if strings.Contains(strings.ToLower(msg), "check-and-set") { + return ErrCAS, "changed underneath you (check-and-set mismatch)" + } + return ErrUnknown, msg + case 503: + return ErrSealed, "vault is sealed or in standby" + default: + return ErrUnknown, msg + } + } + + var netErr net.Error + if errors.As(err, &netErr) { + return ErrNetwork, netErr.Error() + } + + return ErrUnknown, err.Error() +} + +func isTokenInvalid(msg string) bool { + m := strings.ToLower(msg) + return strings.Contains(m, "permission denied") && strings.Contains(m, "token") +} diff --git a/internal/vault/kv.go b/internal/vault/kv.go new file mode 100644 index 0000000..6330d06 --- /dev/null +++ b/internal/vault/kv.go @@ -0,0 +1,381 @@ +package vault + +import ( + "context" + "encoding/json" + "fmt" + "path" + "sort" + + "github.com/hashicorp/vault/api" +) + +// AsInt extracts an int from a decoded-JSON numeric value. api.Secret is +// parsed with json.Decoder.UseNumber() (see api.ParseSecret), so numbers in +// Secret.Data arrive as json.Number, not float64 — a plain `.(float64)` +// type assertion silently fails on every such field. Exported because +// internal/token.infoFromSecret hits the same issue on lookup-self's +// creation_ttl. +func AsInt(v interface{}) (int, bool) { + switch n := v.(type) { + case json.Number: + i, err := n.Int64() + if err != nil { + return 0, false + } + return int(i), true + case float64: + return int(n), true + case int: + return n, true + default: + return 0, false + } +} + +// Listing is the result of listing one logical directory inside a mount. +// Dirs and Leaves are both relative to Path and never overlap; a Vault LIST +// response marks directories with a trailing "/", which is how this split +// is made. +type Listing struct { + Mount string + Path string + Dirs []string + Leaves []string +} + +// VersionMeta is one KV v2 version's metadata, as returned by a data read or +// by the metadata endpoint's versions map. +type VersionMeta struct { + Version int + CreatedTime string + DeletionTime string // "" if not deleted + Destroyed bool +} + +// Secret is the engine-agnostic result of a KV read: the key/value data +// plus, for KV v2, which version it is and that version's metadata. +type Secret struct { + Mount string + Path string + Data map[string]interface{} + Version int // 0 for KV v1 / cubbyhole, which are unversioned + Meta *VersionMeta // nil for KV v1 / cubbyhole +} + +// WriteAck is returned after a successful write. +type WriteAck struct { + Mount string + Path string + Version int // 0 for KV v1 / cubbyhole +} + +// DeleteOp identifies which of KV v2's four distinct delete-ish operations +// was requested; see Confirm() in the plan for why each needs a different +// confirmation prompt. +type DeleteOp uint8 + +const ( + // OpSoftDelete marks the current (or given) versions deleted; recoverable. + OpSoftDelete DeleteOp = iota + // OpUndelete reverses OpSoftDelete for the given versions. + OpUndelete + // OpDestroy permanently destroys the given versions' data. Irreversible. + OpDestroy + // OpDeleteMetadata removes the path and all versions/metadata. Irreversible. + OpDeleteMetadata + // OpDeleteV1 is the plain, irreversible KV v1 / cubbyhole delete. + OpDeleteV1 +) + +func (op DeleteOp) String() string { + switch op { + case OpSoftDelete: + return "deleted" + case OpUndelete: + return "undeleted" + case OpDestroy: + return "destroyed" + case OpDeleteMetadata: + return "deleted (all versions)" + case OpDeleteV1: + return "deleted" + default: + return "unknown op" + } +} + +// DeleteAck is returned after a successful delete-family operation. +type DeleteAck struct { + Mount string + Path string + Op DeleteOp + Versions []int // populated for OpSoftDelete/OpUndelete/OpDestroy +} + +// KV is the engine-agnostic client the UI and headless commands use for all +// secret data operations. It hides the KV v1 vs v2 path mapping entirely — +// callers only ever pass a Mount and a logical path. +// +// KV v1 / cubbyhole: LIST/READ/WRITE/DELETE / +// KV v2: LIST /metadata/ +// READ /data/?version=N +// WRITE /data/ {data: {...}, options: {cas: N}} +// delete family on /{delete,undelete,destroy}/ +// and /metadata/ (DELETE = delete-metadata) +type KV struct { + Client *api.Client + ReadOnly bool +} + +// ErrReadOnly is returned by every mutating KV method when ReadOnly is set. +// This is the real enforcement point for read-only mode — the UI layer's +// disabled keybindings are a courtesy, this is the boundary that actually +// matters (see the plan's "Schreibsicherheit" section). +var ErrReadOnly = fmt.Errorf("vault-tui is in read-only mode") + +// List returns the immediate children of dir within mount ("" = mount root). +func (kv *KV) List(ctx context.Context, m Mount, dir string) (Listing, error) { + var p string + if m.Kind == EngineKVv2 { + p = path.Join(m.Path, "metadata", dir) + } else { + p = path.Join(m.Path, dir) + } + + sec, err := kv.Client.Logical().ListWithContext(ctx, p) + if err != nil { + return Listing{}, err + } + l := Listing{Mount: m.Path, Path: dir} + if sec == nil || sec.Data == nil { + return l, nil + } + keys, _ := sec.Data["keys"].([]interface{}) + for _, k := range keys { + s, ok := k.(string) + if !ok { + continue + } + if len(s) > 0 && s[len(s)-1] == '/' { + l.Dirs = append(l.Dirs, s) + } else { + l.Leaves = append(l.Leaves, s) + } + } + sort.Strings(l.Dirs) + sort.Strings(l.Leaves) + return l, nil +} + +// Read fetches a secret. version == 0 means "latest" (KV v2) or is ignored +// (KV v1 / cubbyhole). +func (kv *KV) Read(ctx context.Context, m Mount, p string, version int) (*Secret, error) { + switch m.Kind { + case EngineKVv2: + full := path.Join(m.Path, "data", p) + var data map[string][]string + if version > 0 { + data = map[string][]string{"version": {fmt.Sprint(version)}} + } + sec, err := kv.Client.Logical().ReadWithDataWithContext(ctx, full, data) + if err != nil { + return nil, err + } + if sec == nil || sec.Data == nil { + return nil, nil // caller maps this to ErrNotFound via Classify's caller-side check + } + inner, _ := sec.Data["data"].(map[string]interface{}) + out := &Secret{Mount: m.Path, Path: p, Data: inner} + if meta, ok := sec.Data["metadata"].(map[string]interface{}); ok { + out.Meta = versionMetaFromMap(meta) + if out.Meta != nil { + out.Version = out.Meta.Version + } + } + return out, nil + default: + full := path.Join(m.Path, p) + sec, err := kv.Client.Logical().ReadWithContext(ctx, full) + if err != nil { + return nil, err + } + if sec == nil || sec.Data == nil { + return nil, nil + } + return &Secret{Mount: m.Path, Path: p, Data: sec.Data}, nil + } +} + +// Write creates or updates a secret. For KV v2, casVersion is the expected +// current version (0 means "must not exist yet", matching Vault's own +// check-and-set semantics); pass useCAS=false to write unconditionally. +func (kv *KV) Write(ctx context.Context, m Mount, p string, data map[string]interface{}, useCAS bool, casVersion int) (*WriteAck, error) { + if kv.ReadOnly { + return nil, ErrReadOnly + } + switch m.Kind { + case EngineKVv2: + full := path.Join(m.Path, "data", p) + body := map[string]interface{}{"data": data} + if useCAS { + body["options"] = map[string]interface{}{"cas": casVersion} + } + sec, err := kv.Client.Logical().WriteWithContext(ctx, full, body) + if err != nil { + return nil, err + } + ack := &WriteAck{Mount: m.Path, Path: p} + if sec != nil && sec.Data != nil { + if v, ok := AsInt(sec.Data["version"]); ok { + ack.Version = v + } + } + return ack, nil + default: + full := path.Join(m.Path, p) + _, err := kv.Client.Logical().WriteWithContext(ctx, full, data) + if err != nil { + return nil, err + } + return &WriteAck{Mount: m.Path, Path: p}, nil + } +} + +// Delete performs one of the DeleteOp variants. versions is required for +// OpSoftDelete/OpUndelete/OpDestroy when targeting specific versions; pass +// nil to soft-delete the current version (KV v2's normal "d" behaviour). +func (kv *KV) Delete(ctx context.Context, m Mount, p string, op DeleteOp, versions []int) (*DeleteAck, error) { + if kv.ReadOnly { + return nil, ErrReadOnly + } + ack := &DeleteAck{Mount: m.Path, Path: p, Op: op, Versions: versions} + + if m.Kind != EngineKVv2 { + if op != OpDeleteV1 { + return nil, fmt.Errorf("delete operation %v is only valid for KV v2 mounts", op) + } + full := path.Join(m.Path, p) + if _, err := kv.Client.Logical().DeleteWithContext(ctx, full); err != nil { + return nil, err + } + return ack, nil + } + + var full string + var body map[string]interface{} + useDelete := false // true => DELETE with no body; false => PUT/POST with body + switch op { + case OpSoftDelete: + if len(versions) > 0 { + full = path.Join(m.Path, "delete", p) + body = map[string]interface{}{"versions": versions} + } else { + // The "delete" endpoint always requires an explicit versions + // array — it has no "current version" shorthand — so soft- + // deleting the current version (nil versions) has to go + // through the plain data endpoint instead, the same way the + // real Vault CLI's "vault kv delete" (no -versions flag) does. + full = path.Join(m.Path, "data", p) + useDelete = true + } + case OpUndelete: + if len(versions) == 0 { + return nil, fmt.Errorf("undelete requires at least one version number") + } + full = path.Join(m.Path, "undelete", p) + body = map[string]interface{}{"versions": versions} + case OpDestroy: + if len(versions) == 0 { + return nil, fmt.Errorf("destroy requires at least one version number") + } + full = path.Join(m.Path, "destroy", p) + body = map[string]interface{}{"versions": versions} + case OpDeleteMetadata: + full = path.Join(m.Path, "metadata", p) + useDelete = true + default: + return nil, fmt.Errorf("unsupported delete operation %v for KV v2", op) + } + + if useDelete { + if _, err := kv.Client.Logical().DeleteWithContext(ctx, full); err != nil { + return nil, err + } + return ack, nil + } + if _, err := kv.Client.Logical().WriteWithContext(ctx, full, body); err != nil { + return nil, err + } + return ack, nil +} + +// Versions returns the version history of a KV v2 secret, newest first. +func (kv *KV) Versions(ctx context.Context, m Mount, p string) ([]VersionMeta, error) { + if m.Kind != EngineKVv2 { + return nil, fmt.Errorf("version history is only available for KV v2 mounts") + } + full := path.Join(m.Path, "metadata", p) + sec, err := kv.Client.Logical().ReadWithContext(ctx, full) + if err != nil { + return nil, err + } + if sec == nil || sec.Data == nil { + return nil, nil + } + versionsRaw, _ := sec.Data["versions"].(map[string]interface{}) + out := make([]VersionMeta, 0, len(versionsRaw)) + for k, raw := range versionsRaw { + vm, ok := raw.(map[string]interface{}) + if !ok { + continue + } + var n int + fmt.Sscanf(k, "%d", &n) + meta := versionMetaFromMap(vm) + if meta == nil { + meta = &VersionMeta{} + } + meta.Version = n + out = append(out, *meta) + } + sort.Slice(out, func(i, j int) bool { return out[i].Version > out[j].Version }) + return out, nil +} + +// Rollback creates a new version whose content is a copy of an older +// version's data — the KV v2-recommended way to "revert" without losing +// history. It is intentionally implemented as Read(old)+Write(new,CAS), +// exactly what `vault kv rollback` does, rather than any special API. +func (kv *KV) Rollback(ctx context.Context, m Mount, p string, toVersion int) (*WriteAck, error) { + if kv.ReadOnly { + return nil, ErrReadOnly + } + old, err := kv.Read(ctx, m, p, toVersion) + if err != nil { + return nil, err + } + if old == nil { + return nil, fmt.Errorf("version %d of %s not found", toVersion, p) + } + current, err := kv.Read(ctx, m, p, 0) + if err != nil { + return nil, err + } + cas := 0 + if current != nil { + cas = current.Version + } + return kv.Write(ctx, m, p, old.Data, true, cas) +} + +func versionMetaFromMap(m map[string]interface{}) *VersionMeta { + vm := &VersionMeta{} + vm.CreatedTime, _ = m["created_time"].(string) + vm.DeletionTime, _ = m["deletion_time"].(string) + vm.Destroyed, _ = m["destroyed"].(bool) + if v, ok := AsInt(m["version"]); ok { + vm.Version = v + } + return vm +} diff --git a/internal/vault/kv_test.go b/internal/vault/kv_test.go new file mode 100644 index 0000000..1b4cbf4 --- /dev/null +++ b/internal/vault/kv_test.go @@ -0,0 +1,231 @@ +package vault + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/hashicorp/vault/api" +) + +// newTestClient builds a real *api.Client pointed at srv, so these tests +// exercise the exact path construction KV sends over the wire — this is +// the single most bug-prone part of any Vault client (see kv.go's doc +// comment), so it gets exhaustive request-shape assertions rather than +// mocking KV itself. +func newTestClient(t *testing.T, srv *httptest.Server) *api.Client { + t.Helper() + cfg := api.DefaultConfig() + cfg.Address = srv.URL + c, err := api.NewClient(cfg) + if err != nil { + t.Fatalf("api.NewClient: %v", err) + } + c.SetToken("test-token") + return c +} + +func jsonBody(w http.ResponseWriter, v interface{}) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v) +} + +func TestKV_List_PathMapping(t *testing.T) { + cases := []struct { + name string + mount Mount + dir string + wantPath string + }{ + {"kv2 root", Mount{Path: "secret/", Kind: EngineKVv2}, "", "/v1/secret/metadata"}, + {"kv2 nested", Mount{Path: "secret/", Kind: EngineKVv2}, "team/prod", "/v1/secret/metadata/team/prod"}, + {"kv1 root", Mount{Path: "kv1/", Kind: EngineKVv1}, "", "/v1/kv1"}, + {"kv1 nested", Mount{Path: "kv1/", Kind: EngineKVv1}, "team", "/v1/kv1/team"}, + {"cubbyhole", Mount{Path: "cubbyhole/", Kind: EngineKVv1}, "", "/v1/cubbyhole"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var gotPath, gotMethod string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath, gotMethod = r.URL.Path, r.Method + jsonBody(w, map[string]interface{}{"data": map[string]interface{}{"keys": []string{"a", "b/"}}}) + })) + defer srv.Close() + + kv := &KV{Client: newTestClient(t, srv)} + _, err := kv.List(context.Background(), c.mount, c.dir) + if err != nil { + t.Fatalf("List: %v", err) + } + if gotMethod != "LIST" && gotMethod != "GET" { + t.Errorf("method = %q, want LIST or GET", gotMethod) + } + if gotPath != c.wantPath { + t.Errorf("path = %q, want %q", gotPath, c.wantPath) + } + }) + } +} + +func TestKV_List_SplitsDirsAndLeaves(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + jsonBody(w, map[string]interface{}{"data": map[string]interface{}{"keys": []string{"leaf1", "dir1/", "leaf2", "dir2/"}}}) + })) + defer srv.Close() + + kv := &KV{Client: newTestClient(t, srv)} + l, err := kv.List(context.Background(), Mount{Path: "secret/", Kind: EngineKVv2}, "") + if err != nil { + t.Fatalf("List: %v", err) + } + if len(l.Dirs) != 2 || len(l.Leaves) != 2 { + t.Fatalf("got %d dirs, %d leaves; want 2/2 (dirs=%v leaves=%v)", len(l.Dirs), len(l.Leaves), l.Dirs, l.Leaves) + } +} + +func TestKV_Read_PathMapping(t *testing.T) { + cases := []struct { + name string + mount Mount + path string + version int + wantPath string + wantQS string + }{ + {"kv2 latest", Mount{Path: "secret/", Kind: EngineKVv2}, "team/db", 0, "/v1/secret/data/team/db", ""}, + {"kv2 versioned", Mount{Path: "secret/", Kind: EngineKVv2}, "team/db", 3, "/v1/secret/data/team/db", "version=3"}, + {"kv1", Mount{Path: "kv1/", Kind: EngineKVv1}, "team/db", 0, "/v1/kv1/team/db", ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var gotPath, gotQuery string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath, gotQuery = r.URL.Path, r.URL.RawQuery + if c.mount.Kind == EngineKVv2 { + jsonBody(w, map[string]interface{}{"data": map[string]interface{}{ + "data": map[string]interface{}{"k": "v"}, + "metadata": map[string]interface{}{"version": 1, "created_time": "now"}, + }}) + } else { + jsonBody(w, map[string]interface{}{"data": map[string]interface{}{"k": "v"}}) + } + })) + defer srv.Close() + + kv := &KV{Client: newTestClient(t, srv)} + _, err := kv.Read(context.Background(), c.mount, c.path, c.version) + if err != nil { + t.Fatalf("Read: %v", err) + } + if gotPath != c.wantPath { + t.Errorf("path = %q, want %q", gotPath, c.wantPath) + } + if c.wantQS != "" && gotQuery != c.wantQS { + t.Errorf("query = %q, want %q", gotQuery, c.wantQS) + } + }) + } +} + +func TestKV_Write_PathAndCAS(t *testing.T) { + var gotPath string + var gotBody map[string]interface{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + _ = json.NewDecoder(r.Body).Decode(&gotBody) + jsonBody(w, map[string]interface{}{"data": map[string]interface{}{"version": 2}}) + })) + defer srv.Close() + + kv := &KV{Client: newTestClient(t, srv)} + ack, err := kv.Write(context.Background(), Mount{Path: "secret/", Kind: EngineKVv2}, "team/db", + map[string]interface{}{"k": "v"}, true, 1) + if err != nil { + t.Fatalf("Write: %v", err) + } + if gotPath != "/v1/secret/data/team/db" { + t.Errorf("path = %q, want /v1/secret/data/team/db", gotPath) + } + opts, ok := gotBody["options"].(map[string]interface{}) + if !ok { + t.Fatalf("request body missing options (cas): %v", gotBody) + } + if cas, _ := opts["cas"].(float64); cas != 1 { + t.Errorf("cas = %v, want 1", opts["cas"]) + } + if ack.Version != 2 { + t.Errorf("ack.Version = %d, want 2 (json.Number decoding regression check)", ack.Version) + } +} + +func TestKV_ReadOnly_RefusesWrites(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("server should never be called in read-only mode; got %s %s", r.Method, r.URL.Path) + })) + defer srv.Close() + + kv := &KV{Client: newTestClient(t, srv), ReadOnly: true} + if _, err := kv.Write(context.Background(), Mount{Path: "secret/", Kind: EngineKVv2}, "x", nil, false, 0); err != ErrReadOnly { + t.Errorf("Write err = %v, want ErrReadOnly", err) + } + if _, err := kv.Delete(context.Background(), Mount{Path: "secret/", Kind: EngineKVv2}, "x", OpSoftDelete, nil); err != ErrReadOnly { + t.Errorf("Delete err = %v, want ErrReadOnly", err) + } +} + +func TestKV_Delete_OpPaths(t *testing.T) { + cases := []struct { + name string + op DeleteOp + versions []int + wantPath string + wantMeth string + }{ + // Soft-deleting the current version (no explicit versions) has no + // shorthand on the "delete" endpoint, so it goes through the plain + // data endpoint instead — the same request "vault kv delete" + // (without -versions) makes. + {"soft delete current version", OpSoftDelete, nil, "/v1/secret/data/x", "DELETE"}, + {"soft delete specific version", OpSoftDelete, []int{2}, "/v1/secret/delete/x", "PUT"}, + {"undelete", OpUndelete, []int{2}, "/v1/secret/undelete/x", "PUT"}, + {"destroy", OpDestroy, []int{2}, "/v1/secret/destroy/x", "PUT"}, + {"delete metadata", OpDeleteMetadata, nil, "/v1/secret/metadata/x", "DELETE"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var gotPath, gotMethod string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath, gotMethod = r.URL.Path, r.Method + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + kv := &KV{Client: newTestClient(t, srv)} + if _, err := kv.Delete(context.Background(), Mount{Path: "secret/", Kind: EngineKVv2}, "x", c.op, c.versions); err != nil { + t.Fatalf("Delete: %v", err) + } + if gotPath != c.wantPath { + t.Errorf("path = %q, want %q", gotPath, c.wantPath) + } + if gotMethod != c.wantMeth { + t.Errorf("method = %q, want %q", gotMethod, c.wantMeth) + } + }) + } +} + +func TestKV_Delete_RequiresVersionsForUndeleteAndDestroy(t *testing.T) { + for _, op := range []DeleteOp{OpUndelete, OpDestroy} { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("server should never be called without explicit versions; got %s %s", r.Method, r.URL.Path) + })) + defer srv.Close() + + kv := &KV{Client: newTestClient(t, srv)} + if _, err := kv.Delete(context.Background(), Mount{Path: "secret/", Kind: EngineKVv2}, "x", op, nil); err == nil { + t.Errorf("Delete(%v, nil versions): want error, got nil", op) + } + } +} diff --git a/internal/vault/mounts.go b/internal/vault/mounts.go new file mode 100644 index 0000000..0f8716d --- /dev/null +++ b/internal/vault/mounts.go @@ -0,0 +1,145 @@ +package vault + +import ( + "context" + "fmt" + "sort" + + "github.com/hashicorp/vault/api" +) + +// EngineKind is what the UI needs to know to talk to a mount's data plane. +type EngineKind uint8 + +const ( + EngineKVv1 EngineKind = iota + EngineKVv2 + EngineOther +) + +// Mount describes one secret engine mount that the current token can see. +type Mount struct { + Path string // e.g. "secret/" — always slash-terminated, as Vault returns it + Type string // "kv", "cubbyhole", "ssh", ... + Kind EngineKind + Description string + Accessor string + Local bool + Options map[string]string +} + +// Supported reports whether internal/vault.KV (see kv.go) knows how to +// browse/read/write this mount. Cubbyhole is included: it behaves like a +// single-version KV v1 mount (no /data or /metadata split, no versioning). +func (m Mount) Supported() bool { + switch m.Type { + case "kv", "cubbyhole": + return true + default: + return false + } +} + +// ListMounts returns every secret engine mount the current token is +// permitted to see. +// +// sys/mounts requires broad "sys" access and commonly returns 403 for +// ordinary tokens (verified against a real Vault instance in this +// project — see the plan's "Erkenntnisse aus der Zielumgebung"). The Vault +// web UI itself falls back to sys/internal/ui/mounts, which is scoped to +// exactly what the caller's token may use and is unauthenticated-safe to +// call broadly. We do the same: try sys/mounts first (it has richer +// `local`/accessor detail when it works), and fall back on any error. +func ListMounts(ctx context.Context, c *api.Client) ([]Mount, error) { + mounts, err := listViaSysMounts(ctx, c) + if err == nil { + return mounts, nil + } + mounts, ferr := listViaUIMounts(ctx, c) + if ferr != nil { + // Report the original sys/mounts error: it is usually the more + // informative one (e.g. "permission denied" vs. a generic parse + // failure), and callers use Classify() on it. + return nil, err + } + return mounts, nil +} + +func listViaSysMounts(ctx context.Context, c *api.Client) ([]Mount, error) { + sec, err := c.Logical().ReadWithContext(ctx, "sys/mounts") + if err != nil { + return nil, err + } + if sec == nil { + return nil, fmt.Errorf("sys/mounts: empty response") + } + out := make([]Mount, 0, len(sec.Data)) + for path, raw := range sec.Data { + entry, ok := raw.(map[string]interface{}) + if !ok { + continue + } + out = append(out, mountFromMap(path, entry)) + } + sortMounts(out) + return out, nil +} + +func listViaUIMounts(ctx context.Context, c *api.Client) ([]Mount, error) { + sec, err := c.Logical().ReadWithContext(ctx, "sys/internal/ui/mounts") + if err != nil { + return nil, err + } + if sec == nil { + return nil, fmt.Errorf("sys/internal/ui/mounts: empty response") + } + secretRaw, _ := sec.Data["secret"].(map[string]interface{}) + out := make([]Mount, 0, len(secretRaw)) + for path, raw := range secretRaw { + entry, ok := raw.(map[string]interface{}) + if !ok { + continue + } + out = append(out, mountFromMap(path, entry)) + } + sortMounts(out) + return out, nil +} + +func mountFromMap(path string, entry map[string]interface{}) Mount { + m := Mount{Path: path} + m.Type, _ = entry["type"].(string) + m.Description, _ = entry["description"].(string) + m.Accessor, _ = entry["accessor"].(string) + m.Local, _ = entry["local"].(bool) + if opts, ok := entry["options"].(map[string]interface{}); ok { + m.Options = make(map[string]string, len(opts)) + for k, v := range opts { + if s, ok := v.(string); ok { + m.Options[k] = s + } + } + } + m.Kind = classifyEngine(m) + return m +} + +func classifyEngine(m Mount) EngineKind { + switch m.Type { + case "kv": + if m.Options != nil && m.Options["version"] == "2" { + return EngineKVv2 + } + return EngineKVv1 + case "cubbyhole": + // Cubbyhole has no /data or /metadata split and no versioning — it + // behaves like a single-version KV v1 mount for our purposes. + return EngineKVv1 + default: + return EngineOther + } +} + +func sortMounts(m []Mount) { + sort.Slice(m, func(i, j int) bool { return m[i].Path < m[j].Path }) +} diff --git a/internal/vault/service.go b/internal/vault/service.go new file mode 100644 index 0000000..f300456 --- /dev/null +++ b/internal/vault/service.go @@ -0,0 +1,32 @@ +package vault + +import ( + "context" + + "github.com/hashicorp/vault/api" +) + +// Service is the single entry point the UI and headless CLI use for all +// Vault data-plane operations. It exists so callers never touch *api.Client +// or KV directly — this is the dependency boundary internal/ui relies on to +// stay free of the hashicorp/vault/api import. +type Service struct { + Client *api.Client + KV *KV + ReadOnly bool +} + +// New wires a Service around an already-authenticated client. readOnly is +// enforced here (via KV.ReadOnly), not just in the UI's disabled +// keybindings — this is the boundary that actually blocks writes. +func New(c *api.Client, readOnly bool) *Service { + return &Service{ + Client: c, + KV: &KV{Client: c, ReadOnly: readOnly}, + ReadOnly: readOnly, + } +} + +func (s *Service) Mounts(ctx context.Context) ([]Mount, error) { + return ListMounts(ctx, s.Client) +} diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..5658011 --- /dev/null +++ b/renovate.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended" + ], + "schedule": ["before 6am on monday"], + "timezone": "Europe/Berlin", + "labels": ["dependencies"], + "postUpdateOptions": ["gomodTidy"], + "packageRules": [ + { + "matchManagers": ["gomod"], + "matchUpdateTypes": ["patch", "minor"], + "groupName": "go dependencies (non-major)", + "automerge": false + }, + { + "matchManagers": ["gomod"], + "matchUpdateTypes": ["major"], + "groupName": "go dependencies (major)", + "automerge": false + }, + { + "matchManagers": ["github-actions"], + "groupName": "actions", + "automerge": false + } + ], + "lockFileMaintenance": { + "enabled": true, + "schedule": ["before 6am on monday"] + } +}