commit
edf6e79327
134 files changed
+27239
No files matched your search
@@ -0,0 +1,407 @@
|
||||
// Command lazysecrets is a terminal UI for browsing secrets in Azure Key
|
||||
// Vault and Databricks secret scopes.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/commands"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/config"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/instance"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/azure"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/databricks"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/app"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "lazysecrets:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// looksLikeRef tells a reference given without "open" from a mistyped
|
||||
// subcommand, so the latter still gets the usage text.
|
||||
func looksLikeRef(s string) bool {
|
||||
return strings.Contains(s, "://") || strings.HasPrefix(s, "@Microsoft.KeyVault(") || strings.HasPrefix(s, "{{secrets/")
|
||||
}
|
||||
|
||||
// starredRepos are the repos the user starred something in: the repo, a
|
||||
// file or a key. Their records keep the repo's folder.
|
||||
func starredRepos(st *store.Store) []string {
|
||||
var out []string
|
||||
for key, n := range st.Nodes() {
|
||||
r, err := ref.Parse(key)
|
||||
if err == nil && r.Kind == provider.KindRepo && len(n.StarredBy) > 0 && n.Source != "" && !slices.Contains(out, n.Source) {
|
||||
out = append(out, n.Source)
|
||||
}
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// checkCommandKeys reports a custom command key that a built-in action or
|
||||
// another command in an overlapping context takes.
|
||||
func checkCommandKeys(km *keys.Map, cmds []commands.Command) error {
|
||||
for i, c := range cmds {
|
||||
if c.Key == "" {
|
||||
continue
|
||||
}
|
||||
name := fmt.Sprintf("custom command %q", cmpOr(c.Description, c.Command))
|
||||
if err := km.CheckExtra(c.Key, name); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, d := range cmds[:i] {
|
||||
overlap := c.Context == d.Context || c.Context == commands.Global || d.Context == commands.Global
|
||||
if d.Key == c.Key && overlap {
|
||||
return fmt.Errorf("%s: %q is also the key of custom command %q", name, c.Key, cmpOr(d.Description, d.Command))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cmpOr(a, b string) string {
|
||||
if a != "" {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// defaultConfigPath is $LAZYSECRETS_CONFIG_FILE, else config.yaml in the
|
||||
// config directory.
|
||||
func defaultConfigPath() string {
|
||||
if env := os.Getenv("LAZYSECRETS_CONFIG_FILE"); env != "" {
|
||||
return env
|
||||
}
|
||||
dir, err := config.Dir()
|
||||
if err != nil {
|
||||
return "config.yaml"
|
||||
}
|
||||
return filepath.Join(dir, "config.yaml")
|
||||
}
|
||||
|
||||
func run() error {
|
||||
configPath := flag.String("config", defaultConfigPath(),
|
||||
"config file; several, comma-separated, are layered in order (also LAZYSECRETS_CONFIG_FILE)")
|
||||
showVersion := flag.Bool("version", false, "print the version and exit")
|
||||
dir := flag.String("dir", "", "start in this directory: its git repo's SOPS files, the ones under it first (default: the current directory)")
|
||||
demo := flag.Bool("demo", false, "browse built-in sample data instead of real backends")
|
||||
timeout := flag.Duration("timeout", 30*time.Second, "timeout for each backend request")
|
||||
icons := flag.Bool("icons", true,
|
||||
"show Nerd Font icons (needs a Nerd Font v3+); disable with --icons=false or LAZYSECRETS_ICONS=0")
|
||||
clipClear := flag.Duration("clipboard-clear", 30*time.Second, "clear copied values from the clipboard after this long (0 disables)")
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintf(flag.CommandLine.Output(), `Usage:
|
||||
lazysecrets [flags] browse secrets
|
||||
lazysecrets [flags] [open] <ref>
|
||||
open a lazysecrets:// link, Key Vault URL,
|
||||
@Microsoft.KeyVault(...) or {{secrets/scope/key}}
|
||||
lazysecrets [flags] get <ref> print the value a lazysecrets:// link points at,
|
||||
for scripts: VAR=$(lazysecrets get <ref>)
|
||||
lazysecrets [flags] keybindings print every action and its keys (with your
|
||||
config applied), as config to copy and edit
|
||||
lazysecrets handler install [--terminal NAME]
|
||||
open lazysecrets:// links from the browser and
|
||||
chat in a new window of this terminal
|
||||
lazysecrets handler uninstall stop opening them
|
||||
lazysecrets launch <ref> open a link in a new terminal window, as
|
||||
the link handler does
|
||||
lazysecrets update [--check] install the newest release in place of this
|
||||
one (--check: just say if there is one)
|
||||
|
||||
Flags:
|
||||
`)
|
||||
flag.PrintDefaults()
|
||||
}
|
||||
flag.Parse()
|
||||
if *showVersion {
|
||||
fmt.Println("lazysecrets", versionString())
|
||||
return nil
|
||||
}
|
||||
|
||||
var open, getRef *ref.Ref
|
||||
printKeys := false
|
||||
switch args := flag.Args(); {
|
||||
case len(args) == 1 && args[0] == "keybindings":
|
||||
printKeys = true
|
||||
case len(args) == 2 && args[0] == "get":
|
||||
r, err := ref.Parse(args[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
getRef = &r
|
||||
case len(args) > 0 && args[0] == "handler":
|
||||
return handler(*configPath, args[1:])
|
||||
case len(args) > 0 && args[0] == "launch":
|
||||
return launch(*configPath, args[1:])
|
||||
case len(args) > 0 && args[0] == "update":
|
||||
return updateCmd(*configPath, args[1:])
|
||||
case len(args) == 0:
|
||||
case args[0] == "open" && len(args) == 2:
|
||||
r, err := ref.Parse(args[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
open = &r
|
||||
case len(args) == 1 && looksLikeRef(args[0]):
|
||||
// lazysecrets <link>, like lazysecrets open <link>
|
||||
r, err := ref.Parse(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
open = &r
|
||||
default:
|
||||
flag.Usage()
|
||||
return fmt.Errorf("unexpected arguments: %v", args)
|
||||
}
|
||||
|
||||
cfg, err := config.LoadAll(strings.Split(*configPath, ","))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
theme, defaultFg, err := styles.FromTheme(cfg.Theme)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
keymap := keys.Default()
|
||||
if err := keymap.Apply(cfg.Keybinding); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkCommandKeys(&keymap, cfg.CustomCommands); err != nil {
|
||||
return err
|
||||
}
|
||||
if printKeys {
|
||||
fmt.Print(keymap.YAML())
|
||||
return nil
|
||||
}
|
||||
// Precedence: flag > environment > config file > default.
|
||||
set := map[string]bool{}
|
||||
flag.Visit(func(f *flag.Flag) { set[f.Name] = true })
|
||||
if !set["timeout"] && cfg.Timeout > 0 {
|
||||
*timeout = cfg.Timeout
|
||||
}
|
||||
if !set["clipboard-clear"] && cfg.ClipboardClear != nil {
|
||||
*clipClear = *cfg.ClipboardClear
|
||||
}
|
||||
expiryDays := 30
|
||||
if cfg.ExpiryWarningDays != nil {
|
||||
expiryDays = *cfg.ExpiryWarningDays
|
||||
}
|
||||
revealTimeout := 60 * time.Second
|
||||
if cfg.RevealTimeout != nil {
|
||||
revealTimeout = *cfg.RevealTimeout
|
||||
}
|
||||
loadDelay := 150 * time.Millisecond
|
||||
if cfg.LoadDelay != nil {
|
||||
loadDelay = *cfg.LoadDelay
|
||||
}
|
||||
if !set["icons"] {
|
||||
switch {
|
||||
case os.Getenv("LAZYSECRETS_ICONS") != "":
|
||||
*icons = os.Getenv("LAZYSECRETS_ICONS") != "0"
|
||||
case cfg.Icons != nil:
|
||||
*icons = *cfg.Icons
|
||||
}
|
||||
}
|
||||
|
||||
// Stars, notes and the layout. Demo mode keeps them in memory, so
|
||||
// sample vaults never end up in the real file.
|
||||
stateDir, err := config.StateDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
statePath := ""
|
||||
if !*demo {
|
||||
statePath = filepath.Join(stateDir, "state.json")
|
||||
}
|
||||
st, err := store.Load(statePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var providers []provider.Provider
|
||||
if *demo {
|
||||
// Two Azure accounts, like a personal and an admin login in az.
|
||||
providers = []provider.Provider{
|
||||
fake.Azure(400*time.Millisecond).As("Azure:alice@example.com/demo", "Azure · alice"),
|
||||
fake.AzureOps(700*time.Millisecond).As("Azure:admin.alice@example.com/demo", "Azure · admin.alice"),
|
||||
fake.Databricks(600 * time.Millisecond),
|
||||
fake.Repos(),
|
||||
}
|
||||
} else {
|
||||
pc := cfg.Providers
|
||||
// One provider per az account. Created even when disabled: AKV-
|
||||
// backed Databricks scopes fall back to reading the vault through
|
||||
// the default account.
|
||||
accounts, err := azure.NewAccounts(azure.Settings{
|
||||
HideSubscriptions: pc.Azure.HideSubscriptions,
|
||||
Vaults: pc.Azure.Vaults,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("azure: %w", err)
|
||||
}
|
||||
if config.On(pc.Azure.Enabled) {
|
||||
for _, a := range accounts {
|
||||
providers = append(providers, a)
|
||||
}
|
||||
}
|
||||
if config.On(pc.Databricks.Enabled) {
|
||||
dbx, err := databricks.New(accounts[0], pc.Databricks.HideProfiles)
|
||||
if err != nil {
|
||||
return fmt.Errorf("databricks: %w", err)
|
||||
}
|
||||
providers = append(providers, dbx)
|
||||
}
|
||||
if config.On(pc.Repos.Enabled) {
|
||||
start := *dir
|
||||
if start == "" {
|
||||
start, _ = os.Getwd()
|
||||
}
|
||||
roots := []string{"~/git"}
|
||||
if pc.Repos.Roots != nil {
|
||||
roots = *pc.Repos.Roots
|
||||
}
|
||||
repos := repo.New(repo.Settings{
|
||||
Dir: start, Paths: pc.Repos.Paths, Roots: roots, Starred: starredRepos(st),
|
||||
Include: pc.Repos.Include, Exclude: pc.Repos.Exclude,
|
||||
})
|
||||
// The repo it started in (shown on top as Repo) and the
|
||||
// others, checked for secret files in the background like
|
||||
// the other sources; repos without any aren't listed.
|
||||
providers = append(providers, repos)
|
||||
}
|
||||
if len(providers) == 0 {
|
||||
return fmt.Errorf("all providers are disabled in %s", *configPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Libraries (notably the Databricks SDK) log token refreshes through
|
||||
// the standard logger, which would draw over the TUI.
|
||||
log.SetOutput(io.Discard)
|
||||
|
||||
if getRef != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
||||
defer cancel()
|
||||
return get(ctx, providers, st, *getRef)
|
||||
}
|
||||
|
||||
// Links opened elsewhere (lazysecrets launch) can come here.
|
||||
var p *tea.Program
|
||||
var srv *instance.Server
|
||||
if !*demo {
|
||||
srv, err = instance.Listen(instance.Dir(stateDir), func(link, front string) error {
|
||||
r, err := ref.Parse(link)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Accept at once: launch waits for the answer, and opens a
|
||||
// window anyway if it's slow. Coming to the front can be
|
||||
// (activating the terminal app).
|
||||
go func() {
|
||||
p.Send(app.OpenLink(r))
|
||||
instance.Raise(front)
|
||||
}()
|
||||
return nil
|
||||
})
|
||||
if err == nil {
|
||||
defer srv.Close()
|
||||
} // else links open in new windows: not worth failing for
|
||||
}
|
||||
var setup *app.HandlerSetup
|
||||
if !*demo {
|
||||
setup = handlerSetup(cfg, *configPath)
|
||||
}
|
||||
onActive := func() {}
|
||||
if srv != nil {
|
||||
onActive = srv.Touch
|
||||
}
|
||||
|
||||
// What was loaded last time, and where it was left in this folder.
|
||||
var cacheFile, placeDir string
|
||||
if !*demo {
|
||||
if d, err := config.CacheDir(); err == nil && config.On(cfg.Cache) {
|
||||
cacheFile = filepath.Join(d, "cache.json")
|
||||
}
|
||||
placeDir = *dir
|
||||
if placeDir == "" {
|
||||
placeDir, _ = os.Getwd()
|
||||
}
|
||||
if abs, err := filepath.Abs(placeDir); err == nil {
|
||||
placeDir = abs
|
||||
}
|
||||
}
|
||||
|
||||
m := app.New(providers, app.Options{
|
||||
Timeout: *timeout,
|
||||
Icons: *icons,
|
||||
ClipboardClear: *clipClear,
|
||||
FlatSources: cfg.SourcesView == "flat",
|
||||
CollapseGroups: config.On(cfg.CollapseGroups),
|
||||
LoadDelay: loadDelay,
|
||||
NoHistory: !config.On(cfg.History),
|
||||
OnActive: onActive,
|
||||
RevealTimeout: revealTimeout,
|
||||
ExpiryWarning: time.Duration(expiryDays) * 24 * time.Hour,
|
||||
HideOnBlur: config.On(cfg.HideOnBlur),
|
||||
HandlerSetup: setup,
|
||||
Updater: updater(cfg.Releases),
|
||||
UpdateCheck: config.On(cfg.UpdateCheck),
|
||||
Keys: &keymap,
|
||||
Commands: cfg.CustomCommands,
|
||||
Version: versionString(),
|
||||
Store: st,
|
||||
CacheFile: cacheFile,
|
||||
ConfigFiles: configFiles(*configPath),
|
||||
StatePath: statePath,
|
||||
Changelog: lazysecrets.Changelog,
|
||||
Dir: placeDir,
|
||||
Open: open,
|
||||
Styles: &theme,
|
||||
DefaultFg: defaultFg,
|
||||
})
|
||||
p = tea.NewProgram(m)
|
||||
final, err := p.Run()
|
||||
if fm, ok := final.(app.Model); ok {
|
||||
_ = fm.Persist() // a cache it couldn't save is no reason to fail
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// configFiles are the config files given, for the status pane: those
|
||||
// that exist, or the first if none does (where to create one).
|
||||
func configFiles(list string) []string {
|
||||
var all, found []string
|
||||
for _, f := range strings.Split(list, ",") {
|
||||
if f = strings.TrimSpace(f); f == "" {
|
||||
continue
|
||||
}
|
||||
all = append(all, f)
|
||||
if _, err := os.Stat(f); err == nil {
|
||||
found = append(found, f)
|
||||
}
|
||||
}
|
||||
if len(found) == 0 && len(all) > 0 {
|
||||
return all[:1]
|
||||
}
|
||||
return found
|
||||
}
|
||||
Reference in new issue
Block a user