diff --git a/README.md b/README.md index 2d6ea21..603a500 100644 --- a/README.md +++ b/README.md @@ -284,8 +284,8 @@ because the engine runs on each server, not here). probes write/read/delete first, then pushes `server backup-target set` to every server with the credentials on stdin, then forgets them: the record keeps the bucket name and per-server prefixes only. A B2 key that cannot delete is the - documented good setup (a compromised server cannot destroy history; a bucket - lifecycle rule removes hidden versions). Rotation re-prompts, pushes, verifies + documented good setup (a compromised server cannot destroy history; the bucket's + lifecycle rules remove hidden versions after 30 days). Rotation re-prompts, pushes, verifies each server and tells you when to revoke the old key. - **Recovery key.** `teploy backup keygen` output shown exactly once (re-auth required; the private key is stored nowhere); the public half is pushed to diff --git a/cmd/teploy-dash/frontend/index.html b/cmd/teploy-dash/frontend/index.html index bee4439..03439a0 100644 --- a/cmd/teploy-dash/frontend/index.html +++ b/cmd/teploy-dash/frontend/index.html @@ -1896,6 +1896,7 @@
Dash probes write/read/delete first. On B2, a key that cannot delete is the recommended setup: a compromised server cannot destroy history.
+Create a B2 application key limited to this bucket with listFiles, readFiles, writeFiles and readBucketLifecycleRules, and without deleteFiles. If you limit it to a file name prefix, use the same value as the prefix here, because every server stores under it.
diff --git a/internal/server/backups_test.go b/internal/server/backups_test.go index f517e93..4ad79c6 100644 --- a/internal/server/backups_test.go +++ b/internal/server/backups_test.go @@ -938,3 +938,18 @@ func TestBackupRouteCapabilities(t *testing.T) { } } } + +// B2 keeps hidden versions forever by default, so the guidance must also expire what +// retention hides, and that rule must never hide live backups. +func TestB2LifecycleRuleCoversHiddenBackups(t *testing.T) { + rule := backupLifecycleRule(&backupStorage{Provider: backupProviderB2}, backupStatTarget{Prefix: "teploy/prod"}) + for _, want := range []string{ + "fileNamePrefix teploy/prod/.incoming/, daysFromUploadingToHiding 1, daysFromHidingToDeleting 1", + "fileNamePrefix teploy/prod/, daysFromHidingToDeleting 30, daysFromUploadingToHiding empty", + "never set daysFromUploadingToHiding on this one", + } { + if !strings.Contains(rule, want) { + t.Errorf("rule %q is missing %q", rule, want) + } + } +} diff --git a/internal/server/backupstorage.go b/internal/server/backupstorage.go index ddb3222..3fd405e 100644 --- a/internal/server/backupstorage.go +++ b/internal/server/backupstorage.go @@ -368,7 +368,7 @@ func (s *Server) probeBackupStorage(ctx context.Context, body *backupStorageRequ case s3probe.DeleteRefused: if body.Provider == backupProviderB2 { res.Error = "" - return res, "This key cannot delete files, which is the recommended B2 setup: a compromised server cannot destroy history. Retention hides files, and the bucket's lifecycle rule removes them. The test object stays under .incoming/ until that rule expires it.", true + return res, "This key cannot delete files, which is the recommended B2 setup: a compromised server cannot destroy history. Retention hides files, and the bucket's lifecycle rules remove them after 30 days. The test object stays under .incoming/ until that rule expires it.", true } if body.DeleteRefusalOK { res.Error = "" @@ -463,12 +463,19 @@ func (s *Server) readBackupTargetShow(ctx context.Context, name string) (*backup // server's .incoming/ tree. func backupLifecycleRule(st *backupStorage, target backupStatTarget) string { incoming := ".incoming/" + root := "" if target.Prefix != "" { - incoming = target.Prefix + "/.incoming/" + root = target.Prefix + "/" + incoming = root + ".incoming/" } warn := " Scope it to that prefix: a rule on the whole bucket deletes the backups." if st != nil && st.Provider == backupProviderB2 || strings.Contains(target.Endpoint, "backblazeb2.com") { - return "Add a B2 lifecycle rule with fileNamePrefix " + incoming + ", daysFromUploadingToHiding 1 and daysFromHidingToDeleting 1." + warn + // B2 keeps hidden versions forever by default, so retention frees no storage + // without the second rule. Its 30 days are also how long hidden backups can + // still be recovered after a compromised server hides them. Overlapping rules + // are allowed; B2 applies the smallest value of each property. + return "Add two B2 lifecycle rules. First: fileNamePrefix " + incoming + ", daysFromUploadingToHiding 1, daysFromHidingToDeleting 1." + warn + + " Second: fileNamePrefix " + root + ", daysFromHidingToDeleting 30, daysFromUploadingToHiding empty. Without it, backups that retention removes stay stored and billed forever; never set daysFromUploadingToHiding on this one." } return `aws s3api put-bucket-lifecycle-configuration --bucket ` + target.Bucket + ` --lifecycle-configuration '{"Rules":[{"ID":"teploy-incoming","Status":"Enabled","Filter":{"Prefix":"` + incoming +