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.
This commit is contained in:
2026-08-14 11:09:03 +02:00
commit ae30ba1240
85 changed files with 9413 additions and 0 deletions
+45
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
/vault-tui
/dist/
*.test
coverage.out
coverage.html
+169
View File
@@ -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
+231
View File
@@ -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=<name> [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_<FIELD>` 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 <path> list secrets under a path
vault-tui read <path> read a secret
vault-tui write <path> k=v ... create/update a secret (needs --write)
vault-tui delete <path> 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.
<details>
<summary>Example <code>config.yaml</code></summary>
```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
```
</details>
### 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/<profile>.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/<nonce>` 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.
+22
View File
@@ -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)
}
}
+88
View File
@@ -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
)
+248
View File
@@ -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=
+32
View File
@@ -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"),
})
}
+42
View File
@@ -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)
}
+110
View File
@@ -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)
}
+11
View File
@@ -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")
}
+29
View File
@@ -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"),
})
}
+32
View File
@@ -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)
}
+46
View File
@@ -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),
})
}
+98
View File
@@ -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/<mount>/verify/<nonce> 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
}
}
}
}
+64
View File
@@ -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
}
+39
View File
@@ -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")})
}
+173
View File
@@ -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 "<nil result>"
}
return "<login result accessor=" + r.Accessor + ">"
}
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 }
+107
View File
@@ -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, "'", "''") + "'"
}
+102
View File
@@ -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)
}
+239
View File
@@ -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/<mount>/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 }
+34
View File
@@ -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
}
+26
View File
@@ -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)
}
+24
View File
@@ -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(`<!DOCTYPE html><html><head><title>vault-tui</title><meta charset="utf-8"></head>
<body style="font-family:sans-serif;text-align:center;margin-top:15%">
<h2>Signed in</h2><p>You can close this tab and return to vault-tui.</p>
</body></html>`)
}
func errorHTML(err error) []byte {
msg := html.EscapeString(err.Error())
return []byte(fmt.Sprintf(`<!DOCTYPE html><html><head><title>vault-tui</title><meta charset="utf-8"></head>
<body style="font-family:sans-serif;text-align:center;margin-top:15%%">
<h2>Sign-in failed</h2><p>%s</p><p>Return to vault-tui and try again.</p>
</body></html>`, msg))
}
+100
View File
@@ -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_<NAME> >
// 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
}
+59
View File
@@ -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/<mount>/<suffix> 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)
}
+86
View File
@@ -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
}
+233
View File
@@ -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
}
+84
View File
@@ -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"
}
+59
View File
@@ -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: "<path>",
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 <path>")
}
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
},
}
}
+39
View File
@@ -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
}
+50
View File
@@ -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: "<path>",
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 <path>")
}
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
},
}
}
+110
View File
@@ -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_<NAME>, 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)
}
}
}
+46
View File
@@ -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
},
}
}
+47
View File
@@ -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}
}
+47
View File
@@ -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
}
+57
View File
@@ -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()))
}
+66
View File
@@ -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: "<path>",
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 <path>")
}
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
},
}
}
+60
View File
@@ -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
}
+61
View File
@@ -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
}
+35
View File
@@ -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 }
+107
View File
@@ -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"
}
+69
View File
@@ -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: "<path> 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 <path> 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
},
}
}
+40
View File
@@ -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
}
+127
View File
@@ -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
}
+86
View File
@@ -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
}
+78
View File
@@ -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
}
+52
View File
@@ -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
}
+414
View File
@@ -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 }
+147
View File
@@ -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")
}
}
+77
View File
@@ -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]
}
+110
View File
@@ -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
}
+141
View File
@@ -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 "<no token info>"
}
return fmt.Sprintf("<token accessor=%s policies=%v>", 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
}
+88
View File
@@ -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 "<no token>"
}
return "<token via " + r.Origin + ">"
}
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
}
+31
View File
@@ -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 }
+68
View File
@@ -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/<profile>.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
}
+51
View File
@@ -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()
}
+192
View File
@@ -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)))
}
+60
View File
@@ -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...))
}
+150
View File
@@ -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
}
+166
View File
@@ -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)
}
+74
View File
@@ -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},
},
}
}
+127
View File
@@ -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},
}
}
+147
View File
@@ -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))
}
+341
View File
@@ -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}
}
}
+90
View File
@@ -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
+43
View File
@@ -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
}
+38
View File
@@ -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
})
}
+394
View File
@@ -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...))
}
+280
View File
@@ -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)
}
+305
View File
@@ -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...))
}
+27
View File
@@ -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...))
}
+116
View File
@@ -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...))
}
+294
View File
@@ -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...))
}
+222
View File
@@ -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)
}
+35
View File
@@ -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
}
+328
View File
@@ -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
}
+68
View File
@@ -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)
}
}
+76
View File
@@ -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
}
+77
View File
@@ -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")
}
+381
View File
@@ -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 <mount>/<path>
// KV v2: LIST <mount>/metadata/<path>
// READ <mount>/data/<path>?version=N
// WRITE <mount>/data/<path> {data: {...}, options: {cas: N}}
// delete family on <mount>/{delete,undelete,destroy}/<path>
// and <mount>/metadata/<path> (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
}
+231
View File
@@ -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)
}
}
}
+145
View File
@@ -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 })
}
+32
View File
@@ -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)
}
+33
View File
@@ -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"]
}
}