vault: bw sync on every read so reads show the latest values
`bw unlock` only decrypts the LOCAL cache, so a persisted (already logged-in) session served stale data — a password changed in the web vault wouldn't appear until the next fresh login. Add a best-effort `bw sync` in openSession (the chokepoint every read shares: get, get --all, list, code, status), so reads reflect current server-side values. Best-effort by design: a transient sync failure warns on stderr and falls back to the cached vault rather than failing the read (an AFK agent shouldn't break on a network blip). status keeps its own explicit sync so a reachability failure still surfaces in its report. CLI v0.10.1. Tests assert the sync runs after unlock and before the read, and that a read still succeeds when sync fails. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
3d948c7033
commit
12a45fa94e
4 changed files with 88 additions and 3 deletions
|
|
@ -234,6 +234,16 @@ only seed-derived path stays the specially-audited `vault code`. Like
|
||||||
`get --json`, the dump is all secret values, so it **refuses a terminal** — pipe
|
`get --json`, the dump is all secret values, so it **refuses a terminal** — pipe
|
||||||
it (`homelab vault get <name> --all | jq`).
|
it (`homelab vault get <name> --all | jq`).
|
||||||
|
|
||||||
|
### v0.10.1 — reads `bw sync` first (always fresh)
|
||||||
|
|
||||||
|
Every vault read (`get`, `get --all`, `list`, `code`, `status`) now runs `bw
|
||||||
|
sync` when opening its session, so it reflects the latest server-side values.
|
||||||
|
`bw unlock` only decrypts the *local* cache, so without this a persisted
|
||||||
|
(already-logged-in) session served stale data — a password changed in the web
|
||||||
|
vault wouldn't show up until the next login. The sync is **best-effort**: a
|
||||||
|
transient failure warns on stderr and falls back to the cached vault rather than
|
||||||
|
failing the read.
|
||||||
|
|
||||||
## Build / install
|
## Build / install
|
||||||
|
|
||||||
Built from source to `/usr/local/bin/homelab` during devvm provisioning
|
Built from source to `/usr/local/bin/homelab` during devvm provisioning
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
v0.10.0
|
v0.10.1
|
||||||
|
|
|
||||||
|
|
@ -274,6 +274,7 @@ func bwUnlockArgs() []string { return []string{"unlock", "--passw
|
||||||
func bwGetArgs(field, name string) []string { return []string{"get", field, name} }
|
func bwGetArgs(field, name string) []string { return []string{"get", field, name} }
|
||||||
func bwItemArgs(name string) []string { return []string{"get", "item", name} }
|
func bwItemArgs(name string) []string { return []string{"get", "item", name} }
|
||||||
func bwStatusArgs() []string { return []string{"status"} }
|
func bwStatusArgs() []string { return []string{"status"} }
|
||||||
|
func bwSyncArgs() []string { return []string{"sync"} }
|
||||||
|
|
||||||
// bwNeedsLogin parses `bw status` JSON and reports whether a `bw login` is
|
// bwNeedsLogin parses `bw status` JSON and reports whether a `bw login` is
|
||||||
// required. Unparseable/empty output → true (safer to attempt login).
|
// required. Unparseable/empty output → true (safer to attempt login).
|
||||||
|
|
@ -440,7 +441,16 @@ func openSession(run cmdRunner, user, uid string) (session, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return session{}, err
|
return session{}, err
|
||||||
}
|
}
|
||||||
return session{env: bwSecretEnv(appdata, creds, sess)}, nil
|
sessEnv := bwSecretEnv(appdata, creds, sess)
|
||||||
|
// Pull the latest server-side state so reads reflect current values. `bw
|
||||||
|
// unlock` only decrypts the LOCAL cache, so a persisted (already-logged-in)
|
||||||
|
// session would otherwise serve stale data until the next login. Best-effort:
|
||||||
|
// a transient sync failure must not break a read — fall back to the cached
|
||||||
|
// vault and warn (status reports reachability separately).
|
||||||
|
if _, err := run("bw", bwSyncArgs(), sessEnv); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "homelab vault: warning: bw sync failed; using cached vault (values may be stale): "+err.Error())
|
||||||
|
}
|
||||||
|
return session{env: sessEnv}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type getOpts struct {
|
type getOpts struct {
|
||||||
|
|
@ -702,7 +712,9 @@ func statusSummary(run cmdRunner, user, uid string) string {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "vault: configured, but unlock/login FAILED (creds stale? run `homelab vault setup`): " + err.Error()
|
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 {
|
// openSession already did a best-effort sync; status re-runs it explicitly so
|
||||||
|
// a reachability failure surfaces in this report rather than only on stderr.
|
||||||
|
if _, err := run("bw", bwSyncArgs(), s.env); err != nil {
|
||||||
return "vault: configured + unlocked, but sync/reachability failed: " + err.Error()
|
return "vault: configured + unlocked, but sync/reachability failed: " + err.Error()
|
||||||
}
|
}
|
||||||
return "vault: configured, unlocked, reachable ✓"
|
return "vault: configured, unlocked, reachable ✓"
|
||||||
|
|
|
||||||
|
|
@ -819,3 +819,66 @@ func TestVaultHelpMentionsAll(t *testing.T) {
|
||||||
t.Error("vault help must document --all")
|
t.Error("vault help must document --all")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- bw sync on read (freshness) ------------------------------------------
|
||||||
|
|
||||||
|
func TestBwSyncArgs(t *testing.T) {
|
||||||
|
if got := bwSyncArgs(); !reflect.DeepEqual(got, []string{"sync"}) {
|
||||||
|
t.Fatalf("bwSyncArgs = %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every read opens a session that first `bw sync`s, so reads reflect the latest
|
||||||
|
// server-side values: `bw unlock` is local-only, so without a sync a persisted
|
||||||
|
// (already-logged-in) session serves a stale local cache.
|
||||||
|
func TestOpenSessionSyncsBeforeRead(t *testing.T) {
|
||||||
|
f := &fakeRunner{out: map[string]string{
|
||||||
|
"vault kv get -field=vaultwarden_master_password secret/workstation/claude-users/emo": "pw",
|
||||||
|
"vault kv get -field=vaultwarden_client_id secret/workstation/claude-users/emo": "user.x",
|
||||||
|
"vault kv get -field=vaultwarden_client_secret secret/workstation/claude-users/emo": "cs",
|
||||||
|
"bw status": `{"status":"locked"}`,
|
||||||
|
"bw unlock": "SESS",
|
||||||
|
"bw sync": "Syncing complete.",
|
||||||
|
"bw get password github": "p@ss",
|
||||||
|
}}
|
||||||
|
uid := fmt.Sprintf("%d", os.Getuid())
|
||||||
|
if _, err := getValue(f.run, "emo", uid, getOpts{name: "github", field: "password"}); err != nil {
|
||||||
|
t.Fatalf("getValue: %v", err)
|
||||||
|
}
|
||||||
|
idx := func(prefix string) int {
|
||||||
|
for i, c := range f.calls {
|
||||||
|
if strings.HasPrefix(strings.Join(c, " "), prefix) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
syncAt, unlockAt, getAt := idx("bw sync"), idx("bw unlock"), idx("bw get password github")
|
||||||
|
if syncAt < 0 {
|
||||||
|
t.Fatal("expected a `bw sync` before the read")
|
||||||
|
}
|
||||||
|
if !(unlockAt < syncAt && syncAt < getAt) {
|
||||||
|
t.Fatalf("order wrong: unlock=%d sync=%d get=%d (want unlock<sync<get)", unlockAt, syncAt, getAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync is best-effort: a transient sync failure must NOT fail the read — the
|
||||||
|
// cached value is still returned (a stderr warning is emitted, not asserted here).
|
||||||
|
func TestReadSucceedsWhenSyncFails(t *testing.T) {
|
||||||
|
f := &fakeRunner{
|
||||||
|
out: map[string]string{
|
||||||
|
"vault kv get -field=vaultwarden_master_password secret/workstation/claude-users/emo": "pw",
|
||||||
|
"vault kv get -field=vaultwarden_client_id secret/workstation/claude-users/emo": "user.x",
|
||||||
|
"vault kv get -field=vaultwarden_client_secret secret/workstation/claude-users/emo": "cs",
|
||||||
|
"bw status": `{"status":"locked"}`,
|
||||||
|
"bw unlock": "SESS",
|
||||||
|
"bw get password github": "p@ss",
|
||||||
|
},
|
||||||
|
err: map[string]error{"bw sync": errors.New("Failed to sync: network error")},
|
||||||
|
}
|
||||||
|
uid := fmt.Sprintf("%d", os.Getuid())
|
||||||
|
val, err := getValue(f.run, "emo", uid, getOpts{name: "github", field: "password"})
|
||||||
|
if err != nil || val != "p@ss" {
|
||||||
|
t.Fatalf("read must succeed despite a sync failure: val=%q err=%v", val, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue