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,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)
|
||||
}
|
||||
Reference in New Issue
Block a user