Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions cmd/teploy-dash/frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1896,6 +1896,7 @@ <h2>Storage</h2>
<label class="checkbox-label" x-show="storage.provider === 'custom'"><input type="checkbox" x-model="storage.allow_internal"> This endpoint is on a private network (admin choice)</label>
<label class="checkbox-label"><input type="checkbox" x-model="storage.delete_refusal_ok"> The store refuses deletes on purpose (a bucket lock)</label>
<p class="text-muted">Dash probes write/read/delete first. On B2, a key that cannot delete is the recommended setup: a compromised server cannot destroy history.</p>
<p class="text-muted" x-show="storage.provider === 'b2'">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.</p>
<button class="btn" type="submit" :disabled="storageBusy" x-text="storageBusy ? 'Working…' : (rotateMode ? 'Push new keys to every server' : 'Probe and connect every server')"></button>
<button class="btn btn-sm" type="button" x-show="rotateMode" @click="rotateMode = false">Cancel</button>
</form>
Expand Down
15 changes: 15 additions & 0 deletions internal/server/backups_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
13 changes: 10 additions & 3 deletions internal/server/backupstorage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand Down Expand Up @@ -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 +
Expand Down
Loading