Restore Under Attack¶
Do not repair repository state automatically during an incident. First preserve evidence, then choose the trusted v3 anchor, then restore with the narrowest credentials practical.
Assume¶
- object storage can delay, delete, replay, or hide objects
- the Kubernetes anchor can be stale or maliciously advanced
- a backup pod may have written bad new state
- old retained versions may exist even when latest listings mislead
- in-cluster logs may be incomplete
Required Inputs¶
Before serving restore traffic, identify or recover:
- repository ID
- wrapping-key source for the keyring envelope
- accepted v3 anchor sequence, commit key, commit object version ID when available, commit body digest, signing key ID, and format-root reference
- format-bound keyring-envelope reference
- backend endpoint, bucket, and prefix
- object-store audit events where available
Backend credentials alone are not sufficient recovery material.
1. Stop New Writes¶
Stop or isolate the read-write gateway before investigating.
kubectl -n <gateway-namespace> scale deployment/<gateway-deployment> --replicas=0
If the deployment must remain online for restore traffic, switch it to
restore-readonly before exposing it to restore clients. For a journaled
installation, reuse the original Job's RS3_INIT_JOURNAL_SECRET name below.
Disabling bootstrap stops initialization; the explicit journal reference keeps
generated provider evidence projected. Preserve existing key/credential Secret
references and the Lease. If evidence comes from an external ConfigMap instead,
keep that reference and omit the journal override.
helm upgrade <release> charts/rs3-gateway \
--namespace <gateway-namespace> \
--reuse-values \
--set-string gateway.mode=restore-readonly \
--set bootstrap.enabled=false \
--set-string bootstrap.existingJournalSecret='<original-journal-secret>' \
--set-string maintenance.mode=''
Do not switch back to read-write until the trusted commit chain and anchor state
are understood.
2. Preserve Evidence¶
Capture the anchor, gateway logs, Velero resources, and storage evidence before cleanup.
kubectl -n <anchor-namespace> get lease <anchor-name> -o yaml > anchor.yaml
kubectl -n <gateway-namespace> logs deployment/<gateway-deployment> --since=24h > gateway.log
kubectl -n velero get backups,restores,podvolumebackups,podvolumerestores -o yaml > velero.yaml
Preserve object-store audit logs outside the affected account when available. Do not include plaintext paths or Kubernetes secrets in shared artifacts.
3. Select a recovery point¶
Use the authenticated live registry¶
When the current anchor and recovery registry are available, list the bounded set of authenticated points before reaching for a bundle:
rs3 recovery-points --limit 100 --format json
rs3 recovery-points --limit 100 --cursor '<next_cursor>' --format json
Review sequence, publish_time_ms, protected_until_ms, and current in the
response. The cursor is tied to the live anchor; restart the listing if the
anchor advances or the cursor is rejected. For a separate Helm reader, use the
historical reader overlay.
With an already configured CLI environment, select the exact sequence in an
isolated read-only gateway:
rs3 serve \
--gateway-mode restore-readonly \
--recovery-point <sequence> \
--bind 127.0.0.1:9081
The selected view rechecks the live anchor and protection deadline before
serving reads. It rejects writes, deletes, multipart mutation, and copy
operations, and does not change the live anchor. Point the restore client at
the isolated S3 endpoint and copy recovered data to an isolated destination;
copying data back to production is a separately reviewed normal write. This
preview implementation does not expose S3 versionId history or select an
arbitrary time. A local retained RustFS Object Lock and Kubernetes Lease exercise
passed graceful read-only and writer restarts, historical AWS CLI/rclone copyout
and range reads, and actual compaction after 256 writes. Deterministic
controlled-time tests cover expiry, renewal, guard faults, and eligible
exact-version GC. This does not qualify a 30-day provider outage or all retained
providers; if the live authority is unavailable, use the trusted-bundle path below.
Fall back to a trusted restore bundle¶
From a healthy cluster or regular operations job, export a trusted bundle and store it outside the object-store account.
cargo run -p rs3-server --features s3,k8s -- export-restore-bundle --output rs3-restore-bundle-unsigned.cbor --format json > rs3-restore-report.json
The CBOR artifact is written to --output, which must not already exist.
JSON stdout is an inspection report, not an importable bundle. Logs go to stderr.
Export a new bundle after each successful backup window or at least before
declaring a repository trial ready for incident restore. The bundle contains
public but integrity-sensitive restore metadata, not wrapping-key material.
Store it outside the object-store account and outside the cluster whose Lease it
may need to recreate. Export prints offline_signature_payload_hex; sign those
canonical bytes with an offline Ed25519 recovery key. Attach and verify the resulting signature before
production import:
cargo run -p rs3-server -- attach-bundle-signature \
--bundle-file rs3-restore-bundle-unsigned.cbor \
--signature-hex <128-hex-character-signature> \
--public-key ed25519:<recovery-public-key-hex> \
--output rs3-restore-bundle.cbor
This command runs offline and verifies the signature before writing the signed CBOR artifact. The signature binds the repository, salt digest, complete anchor, recovery floor, and export time. Keep the signer private key outside the gateway.
RS3_BACKEND_ENDPOINT=s3 \
RS3_BACKEND_BUCKET=<bucket> \
RS3_BACKEND_PREFIX=<repository-prefix> \
RS3_REPOSITORY_ID=<repository-id> \
RS3_RECOVERY_PUBLIC_KEY=ed25519:<recovery-public-key-hex> \
cargo run -p rs3-server --features s3,k8s -- verify-bundle \
--bundle-file rs3-restore-bundle.cbor \
--min-sequence <external-floor-sequence> \
--wrapping-key-hex-file <wrapping-key-hex-file>
If a fresh cluster is missing the Kubernetes Lease, import the trusted v3 anchor after configuring the same repository ID, wrapping-key source, backend, and retention settings. The public salt is recovered from the format root the bundle's anchor binds and checked against the bundle's salt digest.
cargo run -p rs3-server --features s3,k8s -- import-anchor \
--bundle-file rs3-restore-bundle.cbor \
--min-sequence <external-floor-sequence>
verify-bundle does not write storage or anchors. import-anchor repeats
the named signed commit-chain, format-root, and keyring-envelope checks before
writing the missing anchor. Anchor import reads repository, anchor, format-root,
and recovery-signature fields only from --bundle-file. Production import also
requires RS3_RECOVERY_PUBLIC_KEY and refuses a bundle whose anchor sequence is
below the operator-supplied --min-sequence.
It also lists stored v03 commits and refuses to import when it sees a higher
commit sequence than the bundle names. Use --force-rollback only after an
explicit rollback review accepts stranding those newer commits.
Recover an older view with a separate Lease¶
To recover a known earlier state, import its bundle into a separate Kubernetes
Lease and serve that anchor from a second restore-readonly gateway. Keep the
production Lease unchanged. This provides the logical namespace selected by
that bundle; it does not expose historical S3 object versions or select arbitrary times.
Before starting:
- Preserve a trusted, signed bundle from before the unwanted writes, with the matching repository ID, wrapping key and recovery public key.
- Verify that every referenced commit, index, payload, format root and keyring version is still readable and protected through the expected restore duration. The configured retention duration alone does not prove this.
- Pause destructive gateway and offline maintenance for this repository. A side Lease does not register a protected historical root with production GC. Keep writes isolated as described in step 1 and monitor protection deadlines while renewal is paused.
- Choose an unused recovery Lease name, preferably in an isolated namespace. Give the import identity permission to create that Lease without permission to change the production Lease. The serving identity needs read access to the recovery Lease and backend; use read-only backend credentials where practical.
- Review a historical
--min-sequencefloor appropriate to the selected bundle. Do not lower the production recovery floor. The offline signature covers the repository ID and anchor; the outer export timestamp is informational and does not prove which state predates the incident.
Use the existing repository, backend, retention, authentication and key-source configuration from the verified recovery setup. The example runs in a subshell so the recovery anchor and mode do not replace your normal shell settings. Replace the namespace, bundle path and historical floor before running it:
(
set -eu
export RS3_ANCHOR_MODE=kubernetes-lease
export RS3_ANCHOR_NAMESPACE='<recovery-namespace>'
export RS3_ANCHOR_NAME=rs3-incident-restore
export RS3_GATEWAY_MODE=restore-readonly
unset RS3_MAINTENANCE_MODE
export RS3_ALLOW_REPOSITORY_INIT=false
cargo run -p rs3-server --features s3,k8s -- import-anchor \
--bundle-file '<pre-incident-bundle.cbor>' \
--min-sequence '<reviewed-historical-floor>' \
--force-rollback
cargo run -p rs3-server --features s3,k8s -- serve \
--gateway-mode restore-readonly \
--bind 127.0.0.1:9081
)
The import scan looks for newer commits in the backing repository independently
of the Lease name. --force-rollback is therefore required when those commits
exist, even for an unused recovery Lease. It only acknowledges that older view:
import still verifies the signature, external floor and graph, and refuses to
replace a different existing anchor. Stop on any such refusal; do not delete a
Lease to bypass it.
The second gateway listens on a separate loopback port. Also choose separate
admin and metrics listeners if those are configured. For a Kubernetes deployment,
use a separate release and Service with the same repository configuration,
gateway.mode=restore-readonly, and the recovery anchor.namespace and
anchor.name. Never start a read-write gateway against the side Lease.
Point the restore client's S3 endpoint at this isolated view and copy the needed data to an isolated destination. Verify restored bytes or application state as in steps 5 and 6. Recovery does not modify the production namespace; any later copy back is a separately reviewed normal write. Keep the production anchor and its floor unchanged throughout.
4. If No Bundle Exists, Stop¶
When the old Lease is gone and no trusted v3 bundle exists, do not promote backend state by listing storage. A malicious backend can hide newer valid commits. Escalate to an operator-held authority, offline audit trail, or external anchor record before recreating the Lease.
5. Verify Before Restore¶
Verify the trusted v3 anchor before using it for restore. The
rs3 verify-bundle command and the import path check the signed commit
chain, format root, and keyring envelope. After the gateway starts from the
recovered anchor, run the restore client and verify restored application bytes
before declaring the incident restore successful.
6. Restore Read-Only¶
Patch Velero's backup storage location to read-only and serve restore traffic
through restore-readonly.
kubectl -n velero patch backupstoragelocations.velero.io/default \
--type=merge \
-p '{"spec":{"accessMode":"ReadOnly"}}'
helm upgrade <release> charts/rs3-gateway \
--namespace <gateway-namespace> \
--reuse-values \
--set-string gateway.mode=restore-readonly \
--set bootstrap.enabled=false \
--set-string bootstrap.existingJournalSecret='<original-journal-secret>' \
--set-string maintenance.mode=''
Run the restore into an isolated target where practical.
velero --namespace velero restore create <restore-name> \
--from-backup <backup-name> \
--wait
Velero may report PartiallyFailed if the only failure is denied upload of
restore result artifacts after data restore. Accept that status only when all of
the following are true:
- Velero logs show restore-result artifact upload attempts denied by
restore-readonly - pod-volume restore completed
- restored bytes or application checks verify
- gateway/backend counters show no writes during restore
Any other restore error is a failed restore.
Decision Table¶
| Observation | Action |
|---|---|
| Signature fails | Reject commit chain. |
| Parent chain broken | Reject unless it is a trusted snapshot root. |
| Sequence lower than trusted anchor | Treat as rollback. |
| Digest differs from anchor | Fail closed and investigate. |
| Anchor unavailable | Do not accept newer-looking storage state silently. |
| Backend contains commits newer than anchor | Investigate anchor rollback or missed anchor advance. |
| Backend lacks the anchored commit | Treat as unavailable or tampered. |
| Anchor missing but backend objects exist | Require a trusted recovery bundle; do not trust storage alone. |
Break Glass¶
Break-glass restore, if implemented, must require:
- explicit operator command
- selected commit key and sequence
- audit reason or ticket
- read-only backend credentials where possible
- no automatic anchor repair
Its job is data recovery, not making ambiguous state look healthy.