diff --git a/cli/cmd_vault.go b/cli/cmd_vault.go index b575f4b9..d84006ef 100644 --- a/cli/cmd_vault.go +++ b/cli/cmd_vault.go @@ -2,6 +2,7 @@ package main import ( "encoding/base64" + "encoding/json" "fmt" "os" "os/exec" @@ -335,12 +336,131 @@ func clearClipboardAfter(seconds int) { exec.Command("sh", "-c", fmt.Sprintf("sleep %d; printf '%s'", seconds, osc52clear())).Start() } +// listNames extracts "name (id)" from `bw list items` JSON; never values. +func listNames(jsonOut string) []string { + var items []struct { + ID string `json:"id"` + Name string `json:"name"` + } + if err := json.Unmarshal([]byte(jsonOut), &items); err != nil { + return nil + } + out := make([]string, 0, len(items)) + for _, it := range items { + out = append(out, fmt.Sprintf("%s (%s)", it.Name, it.ID)) + } + return out +} + +func runList(run cmdRunner, user, uid, search string) ([]string, error) { + s, err := openSession(run, user, uid) + if err != nil { + return nil, err + } + out, err := run("bw", bwListArgs(search), s.env) + if err != nil { + return nil, err + } + return listNames(out), nil +} + +func vaultList(args []string) error { + hardenProcess() + search := "" + for i := 0; i < len(args); i++ { + if args[i] == "--search" && i+1 < len(args) { + search = args[i+1] + i++ + } else if strings.HasPrefix(args[i], "--search=") { + search = strings.TrimPrefix(args[i], "--search=") + } + } + uid := vaultCurrentUID() + unlock, err := withUserLock(uid) + if err != nil { + return err + } + defer unlock() + names, err := runList(realRunner, vaultCurrentUser(), uid, search) + if err != nil { + return err + } + for _, n := range names { + fmt.Println(n) + } + return nil +} + +func vaultSearch(args []string) error { + if len(args) == 0 { + return fmt.Errorf("usage: homelab vault search ") + } + return vaultList([]string{"--search", strings.Join(args, " ")}) +} + +func vaultCode(args []string) error { + hardenProcess() + if len(args) == 0 { + return fmt.Errorf("usage: homelab vault code ") + } + name := args[0] + uid := vaultCurrentUID() + unlock, err := withUserLock(uid) + if err != nil { + return err + } + defer unlock() + user := vaultCurrentUser() + val, err := getValue(realRunner, user, uid, getOpts{name: name, field: "totp"}) + if err != nil { + return err + } + // TOTP is the most sensitive op: log AND emit an ntfy-bound marker (spec §9a-d). + writeOpLog(opRecord{User: user, Verb: "code", PID: os.Getpid(), PPID: os.Getppid(), ParentComm: parentComm(os.Getppid()), ItemName: name}) + exec.Command("logger", "-t", "homelab-vault-totp", "user="+user+" totp-fetch parent="+parentComm(os.Getppid())).Run() + emitSecret(val) + return nil +} + +// statusSummary reports config/reachability without revealing secrets. +func statusSummary(run cmdRunner, user, uid string) string { + if _, err := loadCreds(run, user); err != nil { + return "vault: not configured — run `homelab vault setup`" + } + s, err := openSession(run, user, uid) + if err != nil { + return "vault: configured, but unlock/login FAILED (creds stale? run `homelab vault setup`): " + err.Error() + } + if _, err := run("bw", []string{"sync"}, s.env); err != nil { + return "vault: configured + unlocked, but sync/reachability failed: " + err.Error() + } + return "vault: configured, unlocked, reachable ✓" +} + +func vaultStatus(args []string) error { + hardenProcess() + uid := vaultCurrentUID() + unlock, err := withUserLock(uid) + if err != nil { + return err + } + defer unlock() + fmt.Println(statusSummary(realRunner, vaultCurrentUser(), uid)) + return nil +} + +func vaultLock(args []string) error { + appdata := bwAppDataDir(vaultCurrentUID()) + _, _ = realRunner("bw", []string{"lock"}, bwBaseEnv(appdata)) + _, err := realRunner("bw", []string{"logout"}, bwBaseEnv(appdata)) + if err == nil { + fmt.Println("locked") + } + return nil // lock/logout best-effort; never error the caller +} + func vaultSetup(args []string) error { return fmt.Errorf("not implemented") } -func vaultStatus(args []string) error { return fmt.Errorf("not implemented") } - -func vaultList(args []string) error { return fmt.Errorf("not implemented") } - func vaultGet(args []string) error { hardenProcess() o, err := parseGetArgs(args) @@ -367,8 +487,3 @@ func vaultGet(args []string) error { return nil } -func vaultSearch(args []string) error { return fmt.Errorf("not implemented") } - -func vaultCode(args []string) error { return fmt.Errorf("not implemented") } - -func vaultLock(args []string) error { return fmt.Errorf("not implemented") } diff --git a/cli/cmd_vault_test.go b/cli/cmd_vault_test.go index 09721ef7..7298c0e7 100644 --- a/cli/cmd_vault_test.go +++ b/cli/cmd_vault_test.go @@ -216,6 +216,23 @@ func TestParseGetArgs(t *testing.T) { } } +func TestListNamesParsing(t *testing.T) { + // bw list items returns JSON; listNames extracts name + id only. + js := `[{"id":"1","name":"GitHub","login":{"username":"u"}},{"id":"2","name":"AWS"}]` + names := listNames(js) + if len(names) != 2 || names[0] != "GitHub (1)" || names[1] != "AWS (2)" { + t.Fatalf("listNames = %v", names) + } +} + +func TestStatusSummaryUnconfigured(t *testing.T) { + f := &fakeRunner{out: map[string]string{}} // no creds + s := statusSummary(f.run, "emo", "1001") + if !strings.Contains(s, "not configured") { + t.Fatalf("status = %q", s) + } +} + // getValue is the testable core: given a runner + opts, returns the secret value. func TestGetValueFlow(t *testing.T) { f := &fakeRunner{out: map[string]string{