R-173 option A in force (hub DB nightly to ep0, restore-tested, alarmed); R-519 proven live on 9202 and closed; R-173/R-232 narrowed; R-882..R-885 opened (332 -> 335); runbook §3 tested; hubdb-check
gates / gates (push) Successful in 59s
gates / gates (push) Successful in 59s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
// hubdb-check — open a RESTORED copy of hub.db with the seal key and report, as counts only, whether every vaulted
|
||||
// console password opens (R-173, runbooks/RUNBOOK-hub-db-offsite-backup.md §3, step 2).
|
||||
//
|
||||
// Usage: hubdb-check <restored hub.db> <file holding OFFSITE_SECRET_KEY>
|
||||
//
|
||||
// Run it on a scratch COPY, never on the live database: opening runs the store's migrations. It prints no secret —
|
||||
// only the number of hosts and of console passwords that opened, failed or are absent. Exit 0 only when every
|
||||
// vaulted console password opened, at least one host exists and at least one password was vaulted; a wrong key
|
||||
// fails every row (the seal is AES-GCM, authenticated). Pinned by main_test.go.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
type result struct{ hosts, opened, failed, absent int }
|
||||
|
||||
func check(dbPath, keyFile string) (result, error) {
|
||||
var r result
|
||||
raw, err := os.ReadFile(keyFile)
|
||||
if err != nil {
|
||||
return r, fmt.Errorf("reading the key file: %w", err)
|
||||
}
|
||||
key, err := store.ParseOffsiteSecretKey(string(raw))
|
||||
if err != nil {
|
||||
return r, fmt.Errorf("the key file does not hold a valid OFFSITE_SECRET_KEY: %w", err)
|
||||
}
|
||||
s, err := store.New(dbPath, log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
return r, err
|
||||
}
|
||||
defer s.Close()
|
||||
if err := s.SetOffsiteSecretKey(key); err != nil {
|
||||
return r, err
|
||||
}
|
||||
hosts, err := s.ListHosts()
|
||||
if err != nil {
|
||||
return r, err
|
||||
}
|
||||
r.hosts = len(hosts)
|
||||
for _, h := range hosts {
|
||||
c, err := s.GetHostRecoveryCredential(h.HostID)
|
||||
switch {
|
||||
case err != nil:
|
||||
r.failed++
|
||||
case c == nil:
|
||||
r.absent++
|
||||
default:
|
||||
r.opened++
|
||||
}
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) != 3 {
|
||||
fmt.Fprintln(os.Stderr, "usage: hubdb-check <restored hub.db> <OFFSITE_SECRET_KEY file>")
|
||||
os.Exit(2)
|
||||
}
|
||||
r, err := check(os.Args[1], os.Args[2])
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "hubdb-check: FAILED:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("hosts=%d console_passwords_opened=%d failed=%d absent=%d\n", r.hosts, r.opened, r.failed, r.absent)
|
||||
if r.hosts == 0 || r.opened == 0 || r.failed > 0 {
|
||||
fmt.Fprintln(os.Stderr, "hubdb-check: FAILED: not every console password opened with this key")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
// Under `go test` every store seals with this fixed key (store.New, testing.Testing()).
|
||||
const testKey = "felhom-hub-test-only-seal-key-32"
|
||||
|
||||
func seededCopy(t *testing.T) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(t.TempDir(), "hub.db")
|
||||
s, err := store.New(p, log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, h := range []string{"h1", "h2"} {
|
||||
if err := s.UpsertHost(&store.Host{HostID: h, CustomerID: "c", APIKey: "k-" + h}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := s.SaveHostRecoveryCredential("h1", "root@pam", "console-pw"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.Close()
|
||||
return p
|
||||
}
|
||||
|
||||
func keyFile(t *testing.T, key string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(t.TempDir(), "k")
|
||||
if err := os.WriteFile(p, []byte(hex.EncodeToString([]byte(key))+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// The consequence the runbook relies on: the SAME key opens every vaulted console password of the restored copy.
|
||||
func TestCheck_SameKeyOpensEveryConsolePassword(t *testing.T) {
|
||||
r, err := check(seededCopy(t), keyFile(t, testKey))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.hosts != 2 || r.opened != 1 || r.failed != 0 || r.absent != 1 {
|
||||
t.Fatalf("got %+v, want hosts=2 opened=1 failed=0 absent=1", r)
|
||||
}
|
||||
}
|
||||
|
||||
// A different key opens nothing — so "opened" is evidence that the key matches, not that the column is readable.
|
||||
func TestCheck_WrongKeyOpensNothing(t *testing.T) {
|
||||
r, err := check(seededCopy(t), keyFile(t, "a-completely-different-key-32byt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.opened != 0 || r.failed != 1 {
|
||||
t.Fatalf("got %+v, want opened=0 failed=1", r)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user