diff --git a/vantagectl/go.mod b/vantagectl/go.mod index c09a1cb..761d620 100644 --- a/vantagectl/go.mod +++ b/vantagectl/go.mod @@ -8,6 +8,7 @@ require ( gitea.hostxtra.co.uk/mrhid6/vantage/shared v0.0.0-00010101000000-000000000000 github.com/spf13/cobra v1.10.2 go.mongodb.org/mongo-driver/v2 v2.8.0 + golang.org/x/term v0.45.0 ) require ( @@ -20,5 +21,6 @@ require ( github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect golang.org/x/crypto v0.54.0 // indirect golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.40.0 // indirect ) diff --git a/vantagectl/go.sum b/vantagectl/go.sum index 91f6299..258b7c5 100644 --- a/vantagectl/go.sum +++ b/vantagectl/go.sum @@ -41,8 +41,12 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= diff --git a/vantagectl/internal/cmd/restore.go b/vantagectl/internal/cmd/restore.go index 2a98555..a74d452 100644 --- a/vantagectl/internal/cmd/restore.go +++ b/vantagectl/internal/cmd/restore.go @@ -1,7 +1,134 @@ package cmd -import "github.com/spf13/cobra" +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "os" + "strings" + "text/tabwriter" + + "gitea.hostxtra.co.uk/mrhid6/vantage/shared/backup" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +// ErrNotConfirmed is returned when a destructive restore was not confirmed. +var ErrNotConfirmed = errors.New("restore not confirmed") func newRestoreCmd() *cobra.Command { - return &cobra.Command{Use: "restore ARCHIVE", Short: "Load an archive into a database"} + var ( + force bool + confirmDB string + ignoreKeyErr bool + ) + + c := &cobra.Command{ + Use: "restore ARCHIVE", + Short: "Load an archive into a database", + Long: "restore loads an archive into a MongoDB database.\n\n" + + "The target is expected to be empty. A database that already holds data\n" + + "is refused unless --force is given, which drops each collection in the\n" + + "archive before loading it. There are no merge semantics: merging two\n" + + "control planes reconciles nothing, and upserting would resurrect\n" + + "revoked keys and deleted users.\n\n" + + "Restore does not touch Redis. Sessions are all it holds, so everyone\n" + + "signs in again.", + Args: cobra.ExactArgs(1), + RunE: func(c *cobra.Command, args []string) error { + ctx := c.Context() + g, err := resolveGlobals(c) + if err != nil { + return err + } + + archive, err := backup.Open(args[0]) + if err != nil { + return err + } + defer archive.Close() + + if force { + isTTY := term.IsTerminal(int(os.Stdin.Fd())) + if err := confirmDestruction(c.InOrStdin(), c.OutOrStdout(), isTTY, + confirmDB, g.Database); err != nil { + return err + } + } + + client, err := connect(ctx, g) + if err != nil { + return err + } + defer client.Disconnect(context.Background()) + + res, err := backup.Restore(ctx, backup.RestoreOptions{ + Client: client, + Database: g.Database, + Archive: archive, + Force: force, + KeyHex: g.KeyHex, + IgnoreKeyMismatch: ignoreKeyErr, + Warn: func(s string) { + fmt.Fprintln(c.ErrOrStderr(), "warning:", s) + }, + }) + if err != nil { + return err + } + + tw := tabwriter.NewWriter(c.OutOrStdout(), 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "COLLECTION\tDOCUMENTS\tINDEXES") + for _, coll := range res.Collections { + fmt.Fprintf(tw, "%s\t%d\t%d\n", coll.Name, coll.Documents, coll.Indexes) + } + tw.Flush() + fmt.Fprintf(c.OutOrStdout(), "\nrestored %d collections into %s\n", + len(res.Collections), g.Database) + return nil + }, + } + + c.Flags().BoolVar(&force, "force", false, + "drop each collection in the archive before loading it") + c.Flags().StringVar(&confirmDB, "confirm-db", "", + "name of the database being overwritten; required with --force when there is no terminal") + c.Flags().BoolVar(&ignoreKeyErr, "ignore-key-mismatch", false, + "restore even though KEY_ENCRYPTION_KEY does not match the archive") + return c +} + +// confirmDestruction gates a --force restore. +// +// On a terminal the operator types the database name. Without one — a +// Kubernetes Job, a CI step, a cron entry — the same assurance comes from +// --confirm-db, whose value must equal the target. Naming the database in the +// argument means a copy-pasted command carries its intended target with it and +// cannot destroy a different one. +func confirmDestruction(in io.Reader, out io.Writer, isTTY bool, confirmDB, database string) error { + if confirmDB != "" { + if confirmDB != database { + return fmt.Errorf("%w: --confirm-db says %q but the target is %q", + ErrNotConfirmed, confirmDB, database) + } + return nil + } + if !isTTY { + return fmt.Errorf("%w: --force with no terminal needs --confirm-db %s", + ErrNotConfirmed, database) + } + + fmt.Fprintf(out, "This drops every collection in the archive from %q and reloads it.\n", database) + fmt.Fprintf(out, "Type the database name to continue: ") + + line, err := bufio.NewReader(in).ReadString('\n') + if err != nil && err != io.EOF { + return fmt.Errorf("%w: %v", ErrNotConfirmed, err) + } + if strings.TrimSpace(line) != database { + return fmt.Errorf("%w: that is not %q", ErrNotConfirmed, database) + } + return nil } diff --git a/vantagectl/internal/cmd/restore_test.go b/vantagectl/internal/cmd/restore_test.go new file mode 100644 index 0000000..ebd7f90 --- /dev/null +++ b/vantagectl/internal/cmd/restore_test.go @@ -0,0 +1,65 @@ +package cmd + +import ( + "bytes" + "errors" + "strings" + "testing" +) + +func TestConfirmDestructionNonTTYRequiresMatchingFlag(t *testing.T) { + var out bytes.Buffer + err := confirmDestruction(strings.NewReader(""), &out, false, "", "vantage") + if !errors.Is(err, ErrNotConfirmed) { + t.Fatalf("got %v, want ErrNotConfirmed", err) + } + if !strings.Contains(err.Error(), "--confirm-db vantage") { + t.Fatalf("the error must tell the operator exactly what to pass, got: %v", err) + } +} + +func TestConfirmDestructionNonTTYRejectsWrongDatabase(t *testing.T) { + var out bytes.Buffer + err := confirmDestruction(strings.NewReader(""), &out, false, "staging", "production") + if !errors.Is(err, ErrNotConfirmed) { + t.Fatalf("got %v, want ErrNotConfirmed", err) + } + if !strings.Contains(err.Error(), "production") { + t.Fatalf("the error must name the real target, got: %v", err) + } +} + +func TestConfirmDestructionNonTTYAcceptsMatchingFlag(t *testing.T) { + var out bytes.Buffer + if err := confirmDestruction(strings.NewReader(""), &out, false, "vantage", "vantage"); err != nil { + t.Fatalf("matching --confirm-db rejected: %v", err) + } +} + +func TestConfirmDestructionTTYRequiresTypedName(t *testing.T) { + var out bytes.Buffer + if err := confirmDestruction(strings.NewReader("vantage\n"), &out, true, "", "vantage"); err != nil { + t.Fatalf("typed name rejected: %v", err) + } + if !strings.Contains(out.String(), "vantage") { + t.Fatalf("the prompt must name the database, got: %s", out.String()) + } +} + +func TestConfirmDestructionTTYRejectsWrongTypedName(t *testing.T) { + var out bytes.Buffer + err := confirmDestruction(strings.NewReader("something else\n"), &out, true, "", "vantage") + if !errors.Is(err, ErrNotConfirmed) { + t.Fatalf("got %v, want ErrNotConfirmed", err) + } +} + +func TestConfirmDestructionTTYFlagSkipsThePrompt(t *testing.T) { + var out bytes.Buffer + if err := confirmDestruction(strings.NewReader(""), &out, true, "vantage", "vantage"); err != nil { + t.Fatalf("matching --confirm-db rejected on a TTY: %v", err) + } + if out.Len() != 0 { + t.Fatalf("--confirm-db must skip the prompt, got: %s", out.String()) + } +} diff --git a/vantagectl/internal/cmd/root.go b/vantagectl/internal/cmd/root.go index d1eeb45..7d0b859 100644 --- a/vantagectl/internal/cmd/root.go +++ b/vantagectl/internal/cmd/root.go @@ -39,7 +39,8 @@ func NewRoot(version string) *cobra.Command { "KEY_ENCRYPTION_KEY is never written into an archive. What an archive\n" + "records is a fingerprint of it, so a restore can tell you that the key\n" + "you hold is the wrong one before it writes a database nobody can read.", - SilenceUsage: true, + SilenceUsage: true, + SilenceErrors: true, } f := root.PersistentFlags() diff --git a/vantagectl/internal/cmd/root_test.go b/vantagectl/internal/cmd/root_test.go index b15285c..7396c89 100644 --- a/vantagectl/internal/cmd/root_test.go +++ b/vantagectl/internal/cmd/root_test.go @@ -91,3 +91,27 @@ func TestVersionIsReported(t *testing.T) { t.Fatalf("Version %q", root.Version) } } + +func TestExplicitlyEmptyFlagDoesNotFallBackToEnvironment(t *testing.T) { + t.Setenv("MONGO_URI", "mongodb://env:27017") + t.Setenv("MONGO_DB", "envdb") + + root := NewRoot("test") + // An operator who writes --db "" means the empty string. Reading the + // environment behind their back would be a different database than the one + // they named, which for a restore is the difference between the right + // machine and the wrong one. + if err := root.PersistentFlags().Set("db", ""); err != nil { + t.Fatalf("set flag: %v", err) + } + + // The URI carries no database path either, so nothing can rescue the empty + // value and resolveGlobals must refuse rather than reach for MONGO_DB. + if err := root.PersistentFlags().Set("mongo-uri", "mongodb://host:27017"); err != nil { + t.Fatalf("set flag: %v", err) + } + + if _, err := resolveGlobals(root); err == nil { + t.Fatal("an explicitly empty --db fell back to MONGO_DB instead of being refused") + } +} diff --git a/vantagectl/internal/cmd/verify.go b/vantagectl/internal/cmd/verify.go index 893ba24..f8e23ae 100644 --- a/vantagectl/internal/cmd/verify.go +++ b/vantagectl/internal/cmd/verify.go @@ -1,7 +1,90 @@ package cmd -import "github.com/spf13/cobra" +import ( + "context" + "fmt" + + "gitea.hostxtra.co.uk/mrhid6/vantage/shared/backup" + "github.com/spf13/cobra" + "go.mongodb.org/mongo-driver/v2/mongo" +) func newVerifyCmd() *cobra.Command { - return &cobra.Command{Use: "verify ARCHIVE", Short: "Check an archive against the key in hand"} + return &cobra.Command{ + Use: "verify ARCHIVE", + Short: "Check an archive against the key in hand", + Long: "verify checks that an archive is intact and that the\n" + + "KEY_ENCRYPTION_KEY in this environment matches the one it was made\n" + + "with.\n\n" + + "Given --mongo-uri it goes further and opens a real ciphertext value\n" + + "from that database. A fingerprint proves two archives agree about a\n" + + "key; only the probe proves the key you hold reads the data.\n\n" + + "Exit status is non-zero when anything is wrong, so this is the command\n" + + "to put on a schedule.", + Args: cobra.ExactArgs(1), + RunE: func(c *cobra.Command, args []string) error { + ctx := c.Context() + + archive, err := backup.Open(args[0]) + if err != nil { + return err + } + defer archive.Close() + + opt := backup.VerifyOptions{Archive: archive} + + // A database is optional here. resolveGlobals fails without a URI, + // so its error is a signal to verify the archive alone rather than + // a reason to stop. + var client *mongo.Client + if g, gerr := resolveGlobals(c); gerr == nil { + client, err = connect(ctx, g) + if err != nil { + return err + } + defer client.Disconnect(context.Background()) + opt.Client = client + opt.Database = g.Database + opt.KeyHex = g.KeyHex + } else { + fmt.Fprintln(c.ErrOrStderr(), + "note: no MongoDB URI, so this checks the archive and the key only") + } + + rep, err := backup.Verify(ctx, opt) + if err != nil { + return err + } + + out := c.OutOrStdout() + fmt.Fprintln(out, "Archive intact, every member matches its checksum") + if rep.ArchiveFingerprint != nil { + fmt.Fprintf(out, "Archive key %s\n", *rep.ArchiveFingerprint) + } + if rep.KeyFingerprint != nil { + fmt.Fprintf(out, "Your key %s\n", *rep.KeyFingerprint) + } + if rep.KeyMatchesArchive { + fmt.Fprintln(out, "Key match yes") + } + switch { + case rep.ProbeDecrypted: + fmt.Fprintf(out, "Live probe decrypted a value from %s\n", rep.ProbeCollection) + case rep.ProbeAttempted: + fmt.Fprintf(out, "Live probe FAILED against %s\n", rep.ProbeCollection) + case opt.Client != nil: + fmt.Fprintln(out, "Live probe skipped; this database stores no ciphertext yet") + } + + if rep.OK() { + fmt.Fprintln(out, "\nThis archive will restore.") + return nil + } + fmt.Fprintln(out) + for _, p := range rep.Problems { + fmt.Fprintln(out, "problem:", p) + } + return fmt.Errorf("verification failed") + }, + } }