328 lines
12 KiB
Go
328 lines
12 KiB
Go
// Package config loads the optional YAML config file.
|
|
package config
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"maps"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.yaml.in/yaml/v3"
|
|
|
|
"git.kebcloud.no/keb/lazysecrets/internal/commands"
|
|
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
|
|
"git.kebcloud.no/keb/lazysecrets/internal/share"
|
|
"git.kebcloud.no/keb/lazysecrets/internal/update"
|
|
)
|
|
|
|
type Config struct {
|
|
UI UI `yaml:"ui"`
|
|
// Values keeps revealed and copied values from lingering.
|
|
Values Values `yaml:"values"`
|
|
// Storage is what's kept between runs.
|
|
Storage Storage `yaml:"storage"`
|
|
// Updates sets how lazysecrets looks for newer releases.
|
|
Updates Updates `yaml:"updates"`
|
|
Providers Providers `yaml:"providers"`
|
|
// LinkHandler sets how lazysecrets:// links open; see linkhandler.
|
|
LinkHandler LinkHandler `yaml:"linkHandler"`
|
|
// Sharing sets how env files are shared; see package share.
|
|
Sharing Sharing `yaml:"sharing"`
|
|
// CustomCommands run shell commands on the selected item; see
|
|
// package commands.
|
|
CustomCommands []commands.Command `yaml:"customCommands"`
|
|
// Keybinding sets keys per context and action, replacing the
|
|
// defaults of each action given; see keys.Map.Apply.
|
|
Keybinding map[string]map[string][]string `yaml:"keybinding"`
|
|
// Theme maps roles to colours, like lazygit's gui.theme; see
|
|
// styles.Roles.
|
|
Theme map[string][]string `yaml:"theme"`
|
|
}
|
|
|
|
type UI struct {
|
|
Icons *bool `yaml:"icons"`
|
|
// RecentIcon marks items used lately (Recent, R) with ◷, in the lists
|
|
// and in lazysecrets list. Defaults to false.
|
|
RecentIcon *bool `yaml:"recentIcon"`
|
|
// SourcesView is "grouped" (default) or "flat"; the backtick key
|
|
// toggles it.
|
|
SourcesView string `yaml:"sourcesView"`
|
|
// CollapseGroups starts the Sources pane with its groups folded;
|
|
// defaults to true.
|
|
CollapseGroups *bool `yaml:"collapseGroups"`
|
|
// LoadDelay is how long the cursor must rest on a row before its
|
|
// contents load; 0s loads at once. Defaults to 150ms.
|
|
LoadDelay *time.Duration `yaml:"loadDelay"`
|
|
// ExpiryWarningDays marks secrets, keys and certificates expiring
|
|
// within this many days; 0 marks only expired ones. Defaults to 30.
|
|
ExpiryWarningDays *int `yaml:"expiryWarningDays"`
|
|
}
|
|
|
|
type Values struct {
|
|
// RevealTimeout hides a revealed value after this long; 0s never.
|
|
// Defaults to 60s.
|
|
RevealTimeout *time.Duration `yaml:"revealTimeout"`
|
|
// HideOnBlur hides a revealed value when the terminal loses focus.
|
|
// Defaults to true.
|
|
HideOnBlur *bool `yaml:"hideOnBlur"`
|
|
ClipboardClear *time.Duration `yaml:"clipboardClear"`
|
|
}
|
|
|
|
type Storage struct {
|
|
// Cache keeps what was loaded (never values) between runs, so lists
|
|
// show at once and refresh in the background; defaults to true.
|
|
Cache *bool `yaml:"cache"`
|
|
// History counts uses of items for the Recent list; defaults to true.
|
|
History *bool `yaml:"history"`
|
|
}
|
|
|
|
type Updates struct {
|
|
// AutoCheck looks for a newer release in the background at startup,
|
|
// at most once per AutoCheckInterval; defaults to true. U and
|
|
// lazysecrets update work either way.
|
|
AutoCheck *bool `yaml:"autoCheck"`
|
|
// AutoCheckInterval defaults to 24h.
|
|
AutoCheckInterval *time.Duration `yaml:"autoCheckInterval"`
|
|
// Sources are the repositories updates come from, tried in
|
|
// order: on GitHub, or a Gitea or Forgejo server. Defaults to GitHub,
|
|
// then git.kebcloud.no (update.DefaultServers).
|
|
Sources []string `yaml:"sources"`
|
|
}
|
|
|
|
type LinkHandler struct {
|
|
// Terminal to open links in, overriding the one handler install
|
|
// detected: kitty, ghostty, wezterm, iterm2, terminal, alacritty,
|
|
// gnome-terminal, konsole or foot.
|
|
Terminal string `yaml:"terminal"`
|
|
// Command opens any other terminal: its arguments, with {cmd} where
|
|
// the lazysecrets command goes, e.g. [foot, "{cmd}"].
|
|
Command []string `yaml:"command"`
|
|
// Reuse hands links to a running lazysecrets, the one last used that
|
|
// can be brought into view, before opening a new window. Defaults to
|
|
// true.
|
|
Reuse *bool `yaml:"reuse"`
|
|
// Prompt offers to set up the link handler at startup when links
|
|
// don't open lazysecrets. Defaults to true.
|
|
Prompt *bool `yaml:"prompt"`
|
|
}
|
|
|
|
type Sharing struct {
|
|
// Key is the secret whose value encrypts shared files (its
|
|
// lazysecrets:// link, Key Vault URL or reference), for repos whose
|
|
// .lazysecrets.yaml doesn't name one.
|
|
Key string `yaml:"key"`
|
|
// KeyRules are what a key's value must be to share with it.
|
|
KeyRules KeyRules `yaml:"keyRules"`
|
|
}
|
|
|
|
// KeyRules default to 32 characters of 2 kinds (share.DefaultRules).
|
|
type KeyRules struct {
|
|
MinLength int `yaml:"minLength"` // at least 16
|
|
// MinClasses is how many kinds of character: lowercase, uppercase,
|
|
// digits, symbols.
|
|
MinClasses int `yaml:"minClasses"`
|
|
}
|
|
|
|
type Providers struct {
|
|
// Timeout bounds each backend request; defaults to 30s. Each
|
|
// provider's own timeout overrides it for that provider.
|
|
Timeout time.Duration `yaml:"timeout"`
|
|
Azure Azure `yaml:"azure"`
|
|
Databricks Databricks `yaml:"databricks"`
|
|
Repos Repos `yaml:"repos"`
|
|
}
|
|
|
|
// Repos are git repositories' secret files (SOPS, .env). Inside a repo,
|
|
// that repo is the only one shown; elsewhere, the ones in Paths and under
|
|
// Roots.
|
|
type Repos struct {
|
|
// Timeout overrides providers.timeout for these requests.
|
|
Timeout time.Duration `yaml:"timeout"`
|
|
// Enabled defaults to true.
|
|
Enabled *bool `yaml:"enabled"`
|
|
Paths []string `yaml:"paths"`
|
|
// Roots are folders of repos. Defaults to repo.DefaultRoots (~/git,
|
|
// ~/src, ~/code and the like); an empty list turns it off.
|
|
Roots *[]string `yaml:"roots"`
|
|
// Include and Exclude are globs relative to a repo's root (** for any
|
|
// depth), for repos without their own .lazysecrets.yaml. Without them,
|
|
// a repo's .sops.yaml creation_rules say where to look.
|
|
Include []string `yaml:"include"`
|
|
Exclude []string `yaml:"exclude"`
|
|
// SecretPatterns say which keys of config files are secrets; Spring,
|
|
// Compose and Dotnet override them for their files, list by list.
|
|
SecretPatterns repo.SecretPatterns `yaml:"secretPatterns"`
|
|
Spring repo.FileType `yaml:"spring"`
|
|
Compose repo.FileType `yaml:"compose"`
|
|
Dotnet repo.FileType `yaml:"dotnet"`
|
|
}
|
|
|
|
type Azure struct {
|
|
// Timeout overrides providers.timeout for these requests.
|
|
Timeout time.Duration `yaml:"timeout"`
|
|
// Enabled defaults to true.
|
|
Enabled *bool `yaml:"enabled"`
|
|
// HideSubscriptions lists subscriptions to leave out, by name or ID.
|
|
HideSubscriptions []string `yaml:"hideSubscriptions"`
|
|
// Vaults are shown under "Pinned vaults", for vaults that can't be
|
|
// discovered (e.g. data-plane access only).
|
|
Vaults []string `yaml:"vaults"`
|
|
}
|
|
|
|
type Databricks struct {
|
|
// Timeout overrides providers.timeout for these requests.
|
|
Timeout time.Duration `yaml:"timeout"`
|
|
// Enabled defaults to true.
|
|
Enabled *bool `yaml:"enabled"`
|
|
HideProfiles []string `yaml:"hideProfiles"`
|
|
}
|
|
|
|
// Dir is $XDG_CONFIG_HOME/lazysecrets, defaulting to ~/.config/lazysecrets
|
|
// on every platform, like most terminal tools.
|
|
func Dir() (string, error) {
|
|
return xdgDir("XDG_CONFIG_HOME", ".config")
|
|
}
|
|
|
|
// CacheDir is $XDG_CACHE_HOME/lazysecrets, defaulting to
|
|
// ~/.cache/lazysecrets. It holds what was loaded, to show it at once.
|
|
func CacheDir() (string, error) {
|
|
return xdgDir("XDG_CACHE_HOME", ".cache")
|
|
}
|
|
|
|
// StateDir is $XDG_STATE_HOME/lazysecrets, defaulting to
|
|
// ~/.local/state/lazysecrets. It holds data the app writes, like favourites.
|
|
func StateDir() (string, error) {
|
|
return xdgDir("XDG_STATE_HOME", filepath.Join(".local", "state"))
|
|
}
|
|
|
|
func xdgDir(env, fallback string) (string, error) {
|
|
base := os.Getenv(env)
|
|
if base == "" {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
base = filepath.Join(home, fallback)
|
|
}
|
|
return filepath.Join(base, "lazysecrets"), nil
|
|
}
|
|
|
|
// LoadAll reads several config files in order, each on top of the ones
|
|
// before, like lazygit's LG_CONFIG_FILE: a later file overrides the
|
|
// settings it sets, and theme roles merge one by one. That lets an editor
|
|
// plugin add a generated theme file to the user's own config. Missing
|
|
// files are skipped. Unknown keys are an error, so typos don't silently do
|
|
// nothing.
|
|
func LoadAll(paths []string) (Config, error) {
|
|
var c Config
|
|
keybinding := map[string]map[string][]string{}
|
|
for _, path := range paths {
|
|
// Keybindings layer per action: a later file setting one action
|
|
// in a context keeps the others set before it.
|
|
c.Keybinding = nil
|
|
if err := decodeInto(&c, path); err != nil {
|
|
return c, err
|
|
}
|
|
for ctx, actions := range c.Keybinding {
|
|
if keybinding[ctx] == nil {
|
|
keybinding[ctx] = map[string][]string{}
|
|
}
|
|
maps.Copy(keybinding[ctx], actions)
|
|
}
|
|
}
|
|
c.Keybinding = keybinding
|
|
return c, c.validate(strings.Join(paths, ", "))
|
|
}
|
|
|
|
func decodeInto(c *Config, path string) error {
|
|
b, err := os.ReadFile(path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dec := yaml.NewDecoder(bytes.NewReader(b))
|
|
dec.KnownFields(true)
|
|
if err := dec.Decode(c); err != nil && !errors.Is(err, io.EOF) {
|
|
return fmt.Errorf("%s: %w", path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c Config) validate(path string) error {
|
|
if v := c.UI.SourcesView; v != "" && v != "grouped" && v != "flat" {
|
|
return fmt.Errorf("%s: ui.sourcesView: %q is not grouped or flat", path, v)
|
|
}
|
|
for _, cmd := range c.CustomCommands {
|
|
if err := cmd.Validate(); err != nil {
|
|
return fmt.Errorf("%s: %w", path, err)
|
|
}
|
|
}
|
|
if d := c.UI.ExpiryWarningDays; d != nil && *d < 0 {
|
|
return fmt.Errorf("%s: ui.expiryWarningDays: %d is negative", path, *d)
|
|
}
|
|
if d := c.UI.LoadDelay; d != nil && (*d < 0 || *d > 5*time.Second) {
|
|
return fmt.Errorf("%s: ui.loadDelay: %s is not between 0s and 5s", path, *d)
|
|
}
|
|
if d := c.Updates.AutoCheckInterval; d != nil && *d <= 0 {
|
|
return fmt.Errorf("%s: updates.autoCheckInterval: %s is not positive (autoCheck: false turns it off)", path, *d)
|
|
}
|
|
for i, r := range c.Updates.Sources {
|
|
if _, err := update.API(r); err != nil || !strings.HasPrefix(r, "https://") {
|
|
return fmt.Errorf("%s: updates.sources[%d]: %q is not an https:// repository address, like %s", path, i, r, update.GitHubRepo)
|
|
}
|
|
}
|
|
if k := c.Sharing.Key; k != "" {
|
|
if _, err := share.ParseKey(k); err != nil {
|
|
return fmt.Errorf("%s: sharing.key: %w", path, err)
|
|
}
|
|
}
|
|
if n := c.Sharing.KeyRules.MinLength; n != 0 && n < 16 {
|
|
return fmt.Errorf("%s: sharing.keyRules.minLength: %d is under 16", path, n)
|
|
}
|
|
if n := c.Sharing.KeyRules.MinClasses; n < 0 || n > 4 {
|
|
return fmt.Errorf("%s: sharing.keyRules.minClasses: %d is not between 1 and 4", path, n)
|
|
}
|
|
for name, d := range map[string]time.Duration{
|
|
"providers.timeout": c.Providers.Timeout,
|
|
"providers.azure.timeout": c.Providers.Azure.Timeout,
|
|
"providers.databricks.timeout": c.Providers.Databricks.Timeout,
|
|
"providers.repos.timeout": c.Providers.Repos.Timeout,
|
|
} {
|
|
if d < 0 {
|
|
return fmt.Errorf("%s: %s: %s is negative", path, name, d)
|
|
}
|
|
}
|
|
if err := errors.Join(c.Providers.Repos.SecretPatterns.Check("providers.repos.secretPatterns"),
|
|
c.Providers.Repos.Spring.SecretPatterns.Check("providers.repos.spring.secretPatterns"),
|
|
c.Providers.Repos.Compose.SecretPatterns.Check("providers.repos.compose.secretPatterns"),
|
|
c.Providers.Repos.Dotnet.SecretPatterns.Check("providers.repos.dotnet.secretPatterns")); err != nil {
|
|
return fmt.Errorf("%s: %w", path, err)
|
|
}
|
|
for i, v := range c.Providers.Azure.Vaults {
|
|
if !strings.HasPrefix(v, "https://") {
|
|
return fmt.Errorf("%s: providers.azure.vaults[%d]: %q is not an https:// vault URL", path, i, v)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// On reports whether an optional enabled flag is unset or true.
|
|
func On(enabled *bool) bool { return enabled == nil || *enabled }
|
|
|
|
// Hidden reports whether name or id appears in list, ignoring case.
|
|
func Hidden(list []string, name, id string) bool {
|
|
for _, h := range list {
|
|
if strings.EqualFold(h, name) || strings.EqualFold(h, id) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|