diff --git a/README.md b/README.md index d01adc4..78e26bc 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,8 @@ A terminal UI for finding, reading and sharing secrets. Inspired by the fantasti - **Favourites**: star the secrets you use most and get back to them in one key. - **Expiry warnings**: secrets, keys and certificates that expire soon are - marked. + marked, listed together in the status pane, and `lazysecrets expiring` + checks every vault from a pipeline. - **Your way**: rebind any key, pick a theme, and add custom commands to run on a secret. - **Try it first**: `lazysecrets --demo` runs on built-in sample data. diff --git a/cmd/lazysecrets/expiring.go b/cmd/lazysecrets/expiring.go new file mode 100644 index 0000000..7975ee0 --- /dev/null +++ b/cmd/lazysecrets/expiring.go @@ -0,0 +1,163 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "slices" + "strings" + "sync" + "text/tabwriter" + "time" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/ref" +) + +// lazysecrets expiring lists what has expired, or will within --days, +// in every vault of every account: for a pipeline or a cron job. Expiry +// dates come with the lists, so it's one request per vault and type, +// a few at a time. Only Key Vault has expiry dates. + +// errExpiring makes the exit status 1 when anything was listed. +var errExpiring = errors.New("expiring") + +// listLimit is how many lists load at once. +const listLimit = 8 + +type expiringOpts struct { + days int + timeout time.Duration + now time.Time +} + +func parseExpiring(args []string, days int) (int, error) { + fs := flag.NewFlagSet("expiring", flag.ContinueOnError) + n := fs.Int("days", days, "list what expires within this many days (default: expiryWarningDays, else 30)") + if err := fs.Parse(args); err != nil { + return 0, err + } + if fs.NArg() > 0 || *n < 0 { + return 0, fmt.Errorf("usage: lazysecrets expiring [--days N]") + } + return *n, nil +} + +func expiring(providers []provider.Provider, o expiringOpts, out, errs io.Writer) error { + var ( + mu sync.Mutex + found []provider.Object + seen = map[string]bool{} + failed int + vaults int + wg sync.WaitGroup + limit = make(chan struct{}, listLimit) + within = o.now.AddDate(0, 0, o.days) + warnErr = func(what string, err error) { + mu.Lock() + defer mu.Unlock() + failed++ + fmt.Fprintf(errs, "lazysecrets: %s: %v\n", what, err) + } + ) + run := func(f func(ctx context.Context)) { + wg.Add(1) + go func() { + defer wg.Done() + limit <- struct{}{} + defer func() { <-limit }() + ctx, cancel := context.WithTimeout(context.Background(), o.timeout) + defer cancel() + f(ctx) + }() + } + var vaultsDone sync.WaitGroup + for _, p := range providers { + if p.Kind() != provider.KindAzure { + continue + } + vaultsDone.Add(1) + run(func(ctx context.Context) { + defer vaultsDone.Done() + srcs, err := p.Sources(ctx) + if err != nil { + warnErr(p.Label(), err) + return + } + for _, src := range srcs { + vaultsDone.Add(1) + run(func(ctx context.Context) { + defer vaultsDone.Done() + cs, err := p.Containers(ctx, src) + if err != nil { + warnErr(src.Name, err) + return + } + for _, c := range cs { + mu.Lock() + fresh := !seen[c.ID] + seen[c.ID] = true + if fresh { + vaults++ + } + mu.Unlock() + if !fresh { // the same vault through another account + continue + } + for _, t := range c.ObjectTypes() { + run(func(ctx context.Context) { + objs, err := p.Objects(ctx, c, t) + if err != nil { + warnErr(c.Name+" "+strings.ToLower(string(t))+"s", err) + return + } + mu.Lock() + defer mu.Unlock() + for _, o := range objs { + if !o.Expires.IsZero() && o.Expires.Before(within) { + found = append(found, o) + } + } + }) + } + } + }) + } + }) + } + // Listing vaults queues the lists of their secrets, so wait for both. + vaultsDone.Wait() + wg.Wait() + + slices.SortFunc(found, func(a, b provider.Object) int { return a.Expires.Compare(b.Expires) }) + if len(found) == 0 { + fmt.Fprintf(errs, "nothing expires within %d days (%d vaults)\n", o.days, vaults) + if failed > 0 { + return fmt.Errorf("%d lists couldn't be read", failed) + } + return nil + } + tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "LEFT\tEXPIRES\tTYPE\tVAULT\tNAME\tLINK") + for _, x := range found { + left := "expired" + if d := x.Expires.Sub(o.now); d > 0 { + left = fmt.Sprintf("%dd", int(d.Hours()/24)) + } + link := "" + if r, err := ref.ForObject(x, ""); err == nil { + link = r.String() + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", left, x.Expires.Local().Format("2006-01-02"), + strings.ToLower(string(x.Type)), x.Container.Name, x.Name, link) + } + if err := tw.Flush(); err != nil { + return err + } + if failed > 0 { + fmt.Fprintf(errs, "lazysecrets: %d lists couldn't be read: the list may be incomplete\n", failed) + } + return errExpiring +} diff --git a/cmd/lazysecrets/expiring_test.go b/cmd/lazysecrets/expiring_test.go new file mode 100644 index 0000000..24faa2b --- /dev/null +++ b/cmd/lazysecrets/expiring_test.go @@ -0,0 +1,41 @@ +package main + +import ( + "bytes" + "errors" + "strings" + "testing" + "time" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/provider/fake" +) + +func TestExpiring(t *testing.T) { + providers := []provider.Provider{fake.Azure(0), fake.Databricks(0)} + now := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + var out, errs bytes.Buffer + err := expiring(providers, expiringOpts{days: 30, timeout: time.Minute, now: now}, &out, &errs) + if !errors.Is(err, errExpiring) { + t.Fatalf("err %v (want errExpiring: something listed)", err) + } + lines := strings.Split(strings.TrimSpace(out.String()), "\n") + if len(lines) != 2 || !strings.HasPrefix(lines[1], "expired 2026-03-01 certificate kv-app-prod old-tls") || + !strings.Contains(lines[1], "lazysecrets://azure/kv-app-prod.vault.azure.net/certificates/old-tls") { + t.Errorf("out:\n%s", out.String()) + } + if !strings.Contains(errs.String(), "kv-restricted") { + t.Errorf("unreadable vaults not said:\n%s", errs.String()) + } + + // Further ahead, the soonest first. + out.Reset() + expiring(providers, expiringOpts{days: 400, timeout: time.Minute, now: now}, &out, &errs) + if got := strings.Count(out.String(), "\n"); got != 4 || !strings.Contains(out.String(), "app-tls") { + t.Errorf("400 days:\n%s", out.String()) + } + + if _, err := parseExpiring([]string{"--days", "-1"}, 30); err == nil { + t.Error("negative days accepted") + } +} diff --git a/cmd/lazysecrets/main.go b/cmd/lazysecrets/main.go index 571de9e..110066f 100644 --- a/cmd/lazysecrets/main.go +++ b/cmd/lazysecrets/main.go @@ -4,6 +4,7 @@ package main import ( "context" + "errors" "flag" "fmt" "io" @@ -33,7 +34,9 @@ import ( ) func main() { - if err := run(); err != nil { + if err := run(); errors.Is(err, errExpiring) { + os.Exit(1) // listed: a pipeline fails on it + } else if err != nil { fmt.Fprintln(os.Stderr, "lazysecrets:", err) os.Exit(1) } @@ -118,6 +121,9 @@ func run() error { @Microsoft.KeyVault(...) or {{secrets/scope/key}} lazysecrets [flags] get print the value a lazysecrets:// link points at, for scripts: VAR=$(lazysecrets get ) + lazysecrets [flags] expiring [--days N] + list what has expired or will within N days + (default 30) in every vault; exits 1 if any lazysecrets [flags] keybindings print every action and its keys (with your config applied), as config to copy and edit lazysecrets handler install [--terminal NAME] @@ -140,6 +146,7 @@ Flags: } var open, getRef *ref.Ref + var expiringArgs []string printKeys := false switch args := flag.Args(); { case len(args) == 1 && args[0] == "keybindings": @@ -150,6 +157,8 @@ Flags: return err } getRef = &r + case len(args) > 0 && args[0] == "expiring": + expiringArgs = append([]string{}, args[1:]...) case len(args) > 0 && args[0] == "handler": return handler(*configPath, args[1:]) case len(args) > 0 && args[0] == "launch": @@ -298,6 +307,14 @@ Flags: // the standard logger, which would draw over the TUI. log.SetOutput(io.Discard) + if expiringArgs != nil { + days, err := parseExpiring(expiringArgs, expiryDays) + if err != nil { + return err + } + return expiring(providers, expiringOpts{days: days, timeout: *timeout, now: time.Now()}, os.Stdout, os.Stderr) + } + if getRef != nil { ctx, cancel := context.WithTimeout(context.Background(), *timeout) defer cancel() diff --git a/docs/azure.md b/docs/azure.md index 85a9e91..82c157a 100644 --- a/docs/azure.md +++ b/docs/azure.md @@ -64,3 +64,23 @@ Secrets, keys and certificates, each on their own tab of the Secrets pane key (PEM public key) or certificate; private key material is never fetched. `v` lists an item's versions. Items expiring within `expiryWarningDays` (30 by default) are marked `⚠ 12d`, expired ones `✗`. + +The status pane (`0`) lists them across every vault loaded, opened or +cached, soonest first, and its line counts them (`⚠ 3 expiring`); `enter` +on one goes to it. That makes no requests of its own: expiry dates come +with the lists. It says how many vaults it covers; `ctrl+a` in search +loads the rest. + +For a pipeline or a cron job, `lazysecrets expiring` lists them in every +vault of every account, loading each vault's lists (a few at a time), and +exits 1 when it lists anything: + +```sh +$ lazysecrets expiring --days 30 +LEFT EXPIRES TYPE VAULT NAME LINK +expired 2026-03-01 certificate kv-app-prod old-tls lazysecrets://azure/kv-app-prod.vault.azure.net/certificates/old-tls +12d 2026-10-23 secret kv-app-prod api-key lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/api-key +``` + +`--days` defaults to `expiryWarningDays`. Lists it can't read are named on +stderr. diff --git a/internal/ui/app/app.go b/internal/ui/app/app.go index 7b0880c..06e247e 100644 --- a/internal/ui/app/app.go +++ b/internal/ui/app/app.go @@ -841,6 +841,12 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) { m.focus = paneDetail // its rows are on the right case m.focus == paneSources && (key.Matches(msg, k.Enter) || key.Matches(msg, k.Reveal)) && m.toggleGroup(): // folded or unfolded a provider group + case m.focus == paneDetail && key.Matches(msg, k.Enter) && m.fieldLink() != "": + r, err := ref.Parse(m.fieldLink()) + if err == nil { + f, _ := m.fields.Selected() + m.startNav(r, actGo, f.Row) + } case key.Matches(msg, k.Enter): if m.focus < paneDetail { m.focus++ @@ -1738,6 +1744,14 @@ func (m Model) problemText(providerID, err string) string { return err } +// fieldLink is the link of the selected Fields row, if it has one. +func (m Model) fieldLink() string { + if f, ok := m.fields.Selected(); ok && m.fields.Shown() { + return f.Link + } + return "" +} + // loginFor is the sign-in L runs: the selected account's, if it needs // one, else the last one asked for. func (m Model) loginFor() *provider.AuthError { diff --git a/internal/ui/app/app_test.go b/internal/ui/app/app_test.go index e526a91..08ab570 100644 --- a/internal/ui/app/app_test.go +++ b/internal/ui/app/app_test.go @@ -1236,3 +1236,25 @@ func TestStatusPane(t *testing.T) { t.Errorf("back to %v", d.m.focus) } } + +func TestExpiringInStatus(t *testing.T) { + t.Parallel() + d := newDriverOpts(t, Options{ExpiryWarning: 30 * 24 * time.Hour}, fake.Azure(0)) + d.keys("3", "]", "]") // load kv-app-prod's certificates + if !strings.Contains(d.screen(), "⚠ 1 expiring") { + t.Errorf("status line:\n%s", d.screen()) + } + d.keys("0") + scr := d.screen() + for _, want := range []string{"expiring ✗ expired old-tls · kv-app-prod", "in 1 of 3 vaults (ctrl+a in search loads all)"} { + if !strings.Contains(scr, want) { + t.Errorf("missing %q:\n%s", want, scr) + } + } + d.keys("3", "[", "[", "0", "enter") // elsewhere, then to it from the status pane + d.m.fields.SelectWhere(func(f panes.Field) bool { return f.Label == "expiring" }) + d.keys("enter") + if _, v, o := d.selected(); v != "kv-app-prod" || o != "old-tls" { + t.Errorf("enter went to %s/%s\n%s", v, o, d.screen()) + } +} diff --git a/internal/ui/app/expiring.go b/internal/ui/app/expiring.go new file mode 100644 index 0000000..c2315a7 --- /dev/null +++ b/internal/ui/app/expiring.go @@ -0,0 +1,75 @@ +package app + +import ( + "cmp" + "fmt" + "slices" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/ref" + "git.kebcloud.no/keb/lazysecrets/internal/ui/panes" +) + +// The status pane lists what has expired or will soon, across every vault +// loaded (opened, searched, or from the cache): expiry dates come with +// the lists, so this makes no requests. enter on one goes to it. + +type expiringItem struct { + o provider.Object + row string // its source's row key, to go to it +} + +// expiringItems are the loaded objects that have expired or will within +// the warning period, soonest first. +func (m Model) expiringItems() []expiringItem { + var out []expiringItem + for _, p := range m.providers { + for _, src := range m.sourceResults[p.ID()].sources { + row := sourceRowKey(p.ID(), src.ID) + for _, c := range m.cache.containers[row].value { + for _, t := range c.ObjectTypes() { + for _, o := range m.cache.objects[containerKey(c)+"|"+string(t)].value { + if state, _ := m.expiry(o); state != notExpiring { + out = append(out, expiringItem{o, row}) + } + } + } + } + } + } + slices.SortFunc(out, func(a, b expiringItem) int { + return cmp.Or(a.o.Expires.Compare(b.o.Expires), cmp.Compare(a.o.Name, b.o.Name)) + }) + return out +} + +// expiringFields are the status pane's rows for them, and how much of +// everything they cover. +func (m Model) expiringFields() []panes.Field { + items := m.expiringItems() + var out []panes.Field + for _, it := range items { + state, left := m.expiry(it.o) + mark := "⚠ " + daysLeft(left) + if state == expired { + mark = "✗ expired" + } + f := panes.Field{Value: mark + " " + it.o.Name + " · " + it.o.Container.Name, Err: state == expired, Row: it.row} + if r, err := ref.ForObject(it.o, ""); err == nil { + f.Link = r.String() + } + out = append(out, f) + } + cov := m.coverageOf(provider.KindAzure) + switch { + case cov.vaults == 0 && len(items) == 0: + return nil + case !cov.complete(): + out = append(out, panes.Field{Value: fmt.Sprintf("in %d of %d vaults", cov.vaultsLoaded, cov.vaults) + + withKey(m.keys.SearchLoadAll, " (%s in search loads all)"), Info: true}) + case len(items) == 0: + out = append(out, panes.Field{Value: fmt.Sprintf("nothing within %d days", int(m.expiryWarning.Hours()/24)), Info: true}) + } + out[0].Label = "expiring" + return out +} diff --git a/internal/ui/app/status.go b/internal/ui/app/status.go index 02628fa..c3da3e5 100644 --- a/internal/ui/app/status.go +++ b/internal/ui/app/status.go @@ -38,6 +38,9 @@ func (m Model) statusLine() string { failing++ } } + if n := len(m.expiringItems()); n > 0 { + parts = append(parts, m.st.Warning.Render(fmt.Sprintf("⚠ %d expiring", n))) + } if accounts > 0 { s := plural(accounts, "account") if failing > 0 { @@ -88,6 +91,8 @@ func (m Model) statusFields() []panes.Field { add(f) } + out = append(out, m.expiringFields()...) + for i, path := range m.configFiles { label := "" if i == 0 { diff --git a/internal/ui/panes/fields.go b/internal/ui/panes/fields.go index 7dd65b6..8b7780d 100644 --- a/internal/ui/panes/fields.go +++ b/internal/ui/panes/fields.go @@ -20,12 +20,15 @@ type Fields struct { // Field is a row: a value under a label. Info rows (loading, errors) // can't be copied; Err ones are errors. A row can also be opened: URL in -// the browser (o), Path in the editor (e), or Provider signed in to (L). +// the browser (o), Path in the editor (e), Provider signed in to (L), or +// Link gone to (enter). type Field struct { Label, Value string Info, Err bool URL, Path, Provider string + // Link is a lazysecrets link enter goes to, in the source of row Row. + Link, Row string } func NewFields() Fields {