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