// 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] open a lazysecrets:// link, Key Vault URL, @Microsoft.KeyVault(...) or {{secrets/scope/key}} lazysecrets [flags] get print the value a lazysecrets:// link points at, for scripts: VAR=$(lazysecrets get ) 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 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(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 , like lazysecrets open 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(), 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 }