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:
@@ -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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/vault-tui
|
||||||
|
/dist/
|
||||||
|
*.test
|
||||||
|
coverage.out
|
||||||
|
coverage.html
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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"),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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"),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")})
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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, "'", "''") + "'"
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)))
|
||||||
|
}
|
||||||
@@ -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...))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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...))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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...))
|
||||||
|
}
|
||||||
@@ -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...))
|
||||||
|
}
|
||||||
@@ -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...))
|
||||||
|
}
|
||||||
@@ -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...))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user