Prepare governed S1 offsite upload
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a02994-7685-7940-bf34-3555b8256018
This commit is contained in:
parent
2c92b97f37
commit
d0a5a27926
11 changed files with 844 additions and 11 deletions
12
Makefile
12
Makefile
|
|
@ -169,6 +169,18 @@ s1-restore-isolated: ## Decrypt only into explicit empty staging: BUNDLE=... DES
|
||||||
@test -n "$(BUNDLE)" && test -n "$(DEST)" && test -n "$(IDENTITY)" || (echo "Usage: make s1-restore-isolated BUNDLE=... DEST=/tmp/... IDENTITY=/path/to/age-identity"; exit 1)
|
@test -n "$(BUNDLE)" && test -n "$(DEST)" && test -n "$(IDENTITY)" || (echo "Usage: make s1-restore-isolated BUNDLE=... DEST=/tmp/... IDENTITY=/path/to/age-identity"; exit 1)
|
||||||
python3 scripts/s1_restore.py "$(BUNDLE)" --extract-to "$(DEST)" --identity "$(IDENTITY)"
|
python3 scripts/s1_restore.py "$(BUNDLE)" --extract-to "$(DEST)" --identity "$(IDENTITY)"
|
||||||
|
|
||||||
|
s1-offsite-review: ## Render the exact railiance-platform upload-contract approval
|
||||||
|
python3 scripts/s1_offsite.py review
|
||||||
|
|
||||||
|
s1-offsite-plan: ## Build a credential-free exact upload plan: BUNDLE=/absolute/path
|
||||||
|
@test -n "$(BUNDLE)" || (echo "Usage: make s1-offsite-plan BUNDLE=/absolute/path/to/s1-backup-*"; exit 1)
|
||||||
|
python3 scripts/s1_offsite.py plan "$(BUNDLE)"
|
||||||
|
|
||||||
|
s1-offsite-upload: ## Contained owner-routed upload: BUNDLE=... APPROVE_S1_OFFSITE_UPLOAD=UPLOAD-S1-OFFSITE-...
|
||||||
|
@test -n "$(BUNDLE)" && test -n "$(APPROVE_S1_OFFSITE_UPLOAD)" || (echo "Run make s1-offsite-plan, then pass BUNDLE and its exact approval"; exit 1)
|
||||||
|
warden access railiance-backup-offsite-lane --field RAILIANCE_BACKUP_NC_TOKEN --exec -- \
|
||||||
|
python3 scripts/s1_offsite.py upload "$(BUNDLE)" --approval "$(APPROVE_S1_OFFSITE_UPLOAD)"
|
||||||
|
|
||||||
s1-backup-deploy: ## Deploy and enable timer: HOST=... APPROVE_S1_BACKUP_DEPLOY=DEPLOY-RAIL-HO-WP-0012-S1-BACKUP-TIMER
|
s1-backup-deploy: ## Deploy and enable timer: HOST=... APPROVE_S1_BACKUP_DEPLOY=DEPLOY-RAIL-HO-WP-0012-S1-BACKUP-TIMER
|
||||||
@test -n "$(HOST)" || (echo "Usage: make s1-backup-deploy HOST=Railiance01 APPROVE_S1_BACKUP_DEPLOY=DEPLOY-RAIL-HO-WP-0012-S1-BACKUP-TIMER"; exit 1)
|
@test -n "$(HOST)" || (echo "Usage: make s1-backup-deploy HOST=Railiance01 APPROVE_S1_BACKUP_DEPLOY=DEPLOY-RAIL-HO-WP-0012-S1-BACKUP-TIMER"; exit 1)
|
||||||
@test "$(APPROVE_S1_BACKUP_DEPLOY)" = "DEPLOY-RAIL-HO-WP-0012-S1-BACKUP-TIMER" || (echo "Refusing deployment: exact approval is absent"; exit 1)
|
@test "$(APPROVE_S1_BACKUP_DEPLOY)" = "DEPLOY-RAIL-HO-WP-0012-S1-BACKUP-TIMER" || (echo "Refusing deployment: exact approval is absent"; exit 1)
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@
|
||||||
loop:
|
loop:
|
||||||
- s1_backup.py
|
- s1_backup.py
|
||||||
- s1_backup_contract.py
|
- s1_backup_contract.py
|
||||||
|
- s1_offsite.py
|
||||||
- s1_restore.py
|
- s1_restore.py
|
||||||
|
|
||||||
- name: Install S1 backup declaration
|
- name: Install S1 backup declaration
|
||||||
|
|
@ -39,6 +40,17 @@
|
||||||
group: root
|
group: root
|
||||||
mode: "0644"
|
mode: "0644"
|
||||||
|
|
||||||
|
- name: Install pending-owner S1 off-site contract metadata
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: "{{ playbook_dir }}/../../spec/{{ item }}"
|
||||||
|
dest: "/usr/local/lib/railiance-infra/s1-backup/spec/{{ item }}"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
loop:
|
||||||
|
- s1-offsite.yaml
|
||||||
|
- s1-offsite-owner-acceptance.yaml
|
||||||
|
|
||||||
- name: Install public recipient policy metadata
|
- name: Install public recipient policy metadata
|
||||||
ansible.builtin.copy:
|
ansible.builtin.copy:
|
||||||
src: "{{ playbook_dir }}/../../.sops.yaml"
|
src: "{{ playbook_dir }}/../../.sops.yaml"
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,50 @@ make s1-backup-prune APPROVE_S1_BACKUP_PRUNE=PRUNE-S1-BACKUPS-...
|
||||||
|
|
||||||
If the set changes between those commands, pruning fails closed. Candidates
|
If the set changes between those commands, pruning fails closed. Candidates
|
||||||
are atomically moved beneath a quarantine directory inside the declared backup
|
are atomically moved beneath a quarantine directory inside the declared backup
|
||||||
root before removal; paths outside that root are never accepted.
|
root before removal; paths outside that root are never accepted. Production
|
||||||
|
pruning also refuses every candidate that lacks a passing off-site receipt
|
||||||
|
bound to the current local manifest.
|
||||||
|
|
||||||
|
## Governed off-site owner interface
|
||||||
|
|
||||||
|
The upload contract is source-controlled in `spec/s1-offsite.yaml`. It packages
|
||||||
|
the two encrypted artifacts and their metadata into one deterministic tar
|
||||||
|
envelope, avoiding a remotely visible partial multi-object backup. Upload uses
|
||||||
|
an immutable object name and `If-None-Match: *`; a collision fails without a
|
||||||
|
success receipt. Neither the endpoint, response body, nor credential is placed
|
||||||
|
in the receipt.
|
||||||
|
|
||||||
|
The contract is intentionally pending `railiance-platform` acceptance. Render
|
||||||
|
the exact review phrase without credentials:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make s1-offsite-review
|
||||||
|
```
|
||||||
|
|
||||||
|
After the owner records acceptance of that exact digest, a fresh bundle can be
|
||||||
|
planned without access to the lane:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make s1-offsite-plan BUNDLE=/opt/backup/railiance/infra/s1-backup-...
|
||||||
|
```
|
||||||
|
|
||||||
|
Only the exact resulting envelope approval can enter the contained upload
|
||||||
|
path. `warden access` injects the primary token into the child environment; it
|
||||||
|
is never accepted in argv, files, State Hub, or command output:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make s1-offsite-upload \
|
||||||
|
BUNDLE=/opt/backup/railiance/infra/s1-backup-... \
|
||||||
|
APPROVE_S1_OFFSITE_UPLOAD=UPLOAD-S1-OFFSITE-...
|
||||||
|
```
|
||||||
|
|
||||||
|
This command remains disabled while
|
||||||
|
`spec/s1-offsite-owner-acceptance.yaml` is pending. Owner acceptance must also
|
||||||
|
confirm that the secret-shaped default discovered in the owner repository is
|
||||||
|
retired or rotated; its value must never be copied into this repository or an
|
||||||
|
approval message. The current route still lacks an authoritative workload
|
||||||
|
identity, so the reviewed interface is attended and is not attached to the
|
||||||
|
systemd timer.
|
||||||
|
|
||||||
## Inspection and isolated restore
|
## Inspection and isolated restore
|
||||||
|
|
||||||
|
|
@ -90,6 +133,6 @@ checks the approval and pins the deployed wrapper to the reviewed 40-character
|
||||||
source revision. This approval installs and enables the timer only; it does
|
source revision. This approval installs and enables the timer only; it does
|
||||||
not authorize pruning, off-host upload, private-key access, or a restore.
|
not authorize pruning, off-host upload, private-key access, or a restore.
|
||||||
|
|
||||||
Off-host transfer remains `RAIL-HO-WP-0012-T05` and belongs to the governed
|
Off-host execution remains pending under `RAIL-HO-WP-0012-T05` and belongs to
|
||||||
`railiance-backup-offsite-lane`. An attended off-host isolated drill remains
|
the governed `railiance-backup-offsite-lane`. An attended off-host isolated
|
||||||
`RAIL-HO-WP-0012-T06`; neither action is implemented or implied here.
|
drill remains `RAIL-HO-WP-0012-T06`; neither live action is implied here.
|
||||||
|
|
|
||||||
|
|
@ -250,11 +250,33 @@ def plan_prune(spec_path: Path, output_dir: Path | None) -> dict[str, Any]:
|
||||||
retention["keep_complete_bundles"],
|
retention["keep_complete_bundles"],
|
||||||
retention["max_total_bytes"],
|
retention["max_total_bytes"],
|
||||||
)
|
)
|
||||||
|
missing_evidence: list[str] = []
|
||||||
|
if declaration["offsite"]["required_before_prune"]:
|
||||||
|
from s1_offsite import OffsiteError, validate_prune_receipt
|
||||||
|
|
||||||
|
receipt_root = Path(declaration["offsite"]["receipt_root"])
|
||||||
|
for candidate in candidates:
|
||||||
|
try:
|
||||||
|
validate_prune_receipt(
|
||||||
|
candidate,
|
||||||
|
spec_path,
|
||||||
|
receipt_root / f"{candidate.name}.offsite.json",
|
||||||
|
)
|
||||||
|
except (OffsiteError, OSError):
|
||||||
|
missing_evidence.append(candidate.name)
|
||||||
|
blocked = bool(missing_evidence)
|
||||||
return {
|
return {
|
||||||
"approval": _prune_approval(root, candidates) if candidates else None,
|
"approval": _prune_approval(root, candidates) if candidates and not blocked else None,
|
||||||
"backup_root": str(root),
|
"backup_root": str(root),
|
||||||
"candidates": [path.name for path in candidates],
|
"candidates": [path.name for path in candidates],
|
||||||
"status": "approval-required" if candidates else "nothing-to-prune",
|
"missing_offsite_evidence": missing_evidence,
|
||||||
|
"status": (
|
||||||
|
"blocked-missing-offsite-evidence"
|
||||||
|
if blocked
|
||||||
|
else "approval-required"
|
||||||
|
if candidates
|
||||||
|
else "nothing-to-prune"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -262,6 +284,8 @@ def apply_prune(spec_path: Path, output_dir: Path | None, approval: str) -> dict
|
||||||
plan = plan_prune(spec_path, output_dir)
|
plan = plan_prune(spec_path, output_dir)
|
||||||
if not plan["candidates"]:
|
if not plan["candidates"]:
|
||||||
return plan
|
return plan
|
||||||
|
if plan["missing_offsite_evidence"]:
|
||||||
|
raise BackupError("prune requires a passing off-site receipt for every candidate")
|
||||||
if approval != plan["approval"]:
|
if approval != plan["approval"]:
|
||||||
raise BackupError("prune approval does not match the current exact candidate set")
|
raise BackupError("prune approval does not match the current exact candidate set")
|
||||||
root = Path(plan["backup_root"])
|
root = Path(plan["backup_root"])
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,19 @@ def validate_spec(payload: Any, *, policy_path: Path) -> dict[str, Any]:
|
||||||
if not isinstance(value, int) or isinstance(value, bool) or not low <= value <= high:
|
if not isinstance(value, int) or isinstance(value, bool) or not low <= value <= high:
|
||||||
errors.append(f"retention.{key} must be an integer from {low} to {high}")
|
errors.append(f"retention.{key} must be an integer from {low} to {high}")
|
||||||
|
|
||||||
|
offsite = payload.get("offsite")
|
||||||
|
if not isinstance(offsite, dict) or not isinstance(
|
||||||
|
offsite.get("required_before_prune"), bool
|
||||||
|
):
|
||||||
|
errors.append("offsite must declare boolean required_before_prune and receipt_root")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
receipt_root = _absolute_path(offsite.get("receipt_root"), "offsite.receipt_root")
|
||||||
|
if backup_root and receipt_root != backup_root + "/offsite-receipts":
|
||||||
|
errors.append("offsite.receipt_root must be the bounded backup-root receipt directory")
|
||||||
|
except BackupContractError as exc:
|
||||||
|
errors.append(str(exc))
|
||||||
|
|
||||||
exclusions = payload.get("excluded_prefixes")
|
exclusions = payload.get("excluded_prefixes")
|
||||||
normalized_exclusions: list[str] = []
|
normalized_exclusions: list[str] = []
|
||||||
if not isinstance(exclusions, list):
|
if not isinstance(exclusions, list):
|
||||||
|
|
|
||||||
456
scripts/s1_offsite.py
Executable file
456
scripts/s1_offsite.py
Executable file
|
|
@ -0,0 +1,456 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Plan or execute one collision-safe S1 off-site envelope upload."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from s1_backup import _sha256
|
||||||
|
from s1_backup_contract import BackupContractError
|
||||||
|
from s1_restore import RestoreError, validate_bundle
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||||
|
EXPECTED_MEMBERS = {
|
||||||
|
"manifest.json",
|
||||||
|
"receipt.json",
|
||||||
|
"os-config.tar.gz.age",
|
||||||
|
"packages.txt.age",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class OffsiteError(RuntimeError):
|
||||||
|
"""The off-site contract, evidence, or upload failed closed."""
|
||||||
|
|
||||||
|
|
||||||
|
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||||
|
"""Keep a credential-bearing file-drop request on its reviewed origin."""
|
||||||
|
|
||||||
|
def redirect_request(
|
||||||
|
self,
|
||||||
|
req: Any,
|
||||||
|
fp: Any,
|
||||||
|
code: int,
|
||||||
|
msg: str,
|
||||||
|
headers: Any,
|
||||||
|
newurl: str,
|
||||||
|
) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _read_yaml(path: Path, label: str) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
payload = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, yaml.YAMLError) as exc:
|
||||||
|
raise OffsiteError(f"cannot read {label}") from exc
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise OffsiteError(f"{label} must be a YAML object")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def contract_sha256(path: Path) -> str:
|
||||||
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def load_contract(path: Path, *, fixture_http: bool = False) -> dict[str, Any]:
|
||||||
|
payload = _read_yaml(path, "off-site contract")
|
||||||
|
errors: list[str] = []
|
||||||
|
if payload.get("schema_version") != "1.0":
|
||||||
|
errors.append("schema_version must be 1.0")
|
||||||
|
if payload.get("route_id") != "railiance-backup-offsite-lane":
|
||||||
|
errors.append("route_id must be railiance-backup-offsite-lane")
|
||||||
|
if payload.get("owner_repo") != "railiance-platform":
|
||||||
|
errors.append("owner_repo must be railiance-platform")
|
||||||
|
|
||||||
|
provider = payload.get("provider")
|
||||||
|
if not isinstance(provider, dict):
|
||||||
|
errors.append("provider must be an object")
|
||||||
|
provider = {}
|
||||||
|
if provider.get("kind") != "nextcloud-file-drop":
|
||||||
|
errors.append("provider.kind must be nextcloud-file-drop")
|
||||||
|
base_url = provider.get("base_url")
|
||||||
|
if not isinstance(base_url, str):
|
||||||
|
errors.append("provider.base_url must be a URL")
|
||||||
|
else:
|
||||||
|
parsed = urllib.parse.urlsplit(base_url)
|
||||||
|
allowed_scheme = "http" if fixture_http else "https"
|
||||||
|
if parsed.scheme != allowed_scheme or not parsed.netloc or parsed.query or parsed.fragment:
|
||||||
|
errors.append(f"provider.base_url must be a bounded {allowed_scheme} URL")
|
||||||
|
if "@" in parsed.netloc or parsed.username or parsed.password:
|
||||||
|
errors.append("provider.base_url must contain no user information")
|
||||||
|
prefix = provider.get("remote_prefix")
|
||||||
|
if not isinstance(prefix, str) or not re.fullmatch(r"[a-z0-9][a-z0-9-]{1,62}", prefix):
|
||||||
|
errors.append("provider.remote_prefix must be a safe single path segment")
|
||||||
|
|
||||||
|
transport = payload.get("transport")
|
||||||
|
expected_transport = {
|
||||||
|
"method": "PUT",
|
||||||
|
"content_type": "application/x-tar",
|
||||||
|
"if_none_match": "*",
|
||||||
|
"success_codes": [201, 204],
|
||||||
|
"authentication": "basic-token-empty-password",
|
||||||
|
"secret_env": "RAILIANCE_BACKUP_NC_TOKEN",
|
||||||
|
"response_body": "suppressed",
|
||||||
|
}
|
||||||
|
if transport != expected_transport:
|
||||||
|
errors.append("transport must match the reviewed write-only PUT contract")
|
||||||
|
|
||||||
|
envelope = payload.get("envelope")
|
||||||
|
if not isinstance(envelope, dict):
|
||||||
|
errors.append("envelope must be an object")
|
||||||
|
envelope = {}
|
||||||
|
if envelope.get("format") != "deterministic-ustar":
|
||||||
|
errors.append("envelope.format must be deterministic-ustar")
|
||||||
|
if set(envelope.get("members", [])) != EXPECTED_MEMBERS:
|
||||||
|
errors.append("envelope.members must contain exactly the encrypted bundle and metadata")
|
||||||
|
if envelope.get("object_name_template") != "railiance-infra__{bundle_id}__s1-envelope.tar":
|
||||||
|
errors.append("envelope.object_name_template is not the reviewed immutable name")
|
||||||
|
|
||||||
|
acceptance_file = payload.get("owner_acceptance_file")
|
||||||
|
if acceptance_file != "spec/s1-offsite-owner-acceptance.yaml":
|
||||||
|
errors.append("owner_acceptance_file must name the bounded repository acceptance record")
|
||||||
|
receipt_root = payload.get("receipt_root")
|
||||||
|
if not isinstance(receipt_root, str) or not receipt_root.startswith("/") or receipt_root in {
|
||||||
|
"/",
|
||||||
|
"/etc",
|
||||||
|
"/home",
|
||||||
|
"/root",
|
||||||
|
"/var",
|
||||||
|
}:
|
||||||
|
errors.append("receipt_root must be a dedicated absolute directory")
|
||||||
|
if errors:
|
||||||
|
raise OffsiteError("off-site contract failed:\n- " + "\n- ".join(errors))
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def load_acceptance(contract_path: Path, contract: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
acceptance_path = ROOT / contract["owner_acceptance_file"]
|
||||||
|
# Fixture contracts carry their acceptance beside their fixture repository root.
|
||||||
|
if contract_path.resolve().parent.parent != ROOT:
|
||||||
|
acceptance_path = contract_path.resolve().parent.parent / contract["owner_acceptance_file"]
|
||||||
|
payload = _read_yaml(acceptance_path, "owner acceptance")
|
||||||
|
expected_digest = contract_sha256(contract_path)
|
||||||
|
if payload.get("schema_version") != "1.0" or payload.get("owner_repo") != contract["owner_repo"]:
|
||||||
|
raise OffsiteError("owner acceptance identity is invalid")
|
||||||
|
if payload.get("status") == "accepted":
|
||||||
|
if payload.get("contract_sha256") != expected_digest:
|
||||||
|
raise OffsiteError("owner acceptance does not bind the current contract digest")
|
||||||
|
try:
|
||||||
|
uuid.UUID(str(payload.get("decision_id")))
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
raise OffsiteError("accepted owner contract requires a decision UUID") from exc
|
||||||
|
if not isinstance(payload.get("accepted_at"), str) or not payload["accepted_at"].endswith("Z"):
|
||||||
|
raise OffsiteError("accepted owner contract requires accepted_at")
|
||||||
|
elif payload.get("status") != "pending":
|
||||||
|
raise OffsiteError("owner acceptance status must be pending or accepted")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def review_contract(contract_path: Path, *, fixture_http: bool = False) -> dict[str, Any]:
|
||||||
|
contract = load_contract(contract_path, fixture_http=fixture_http)
|
||||||
|
digest = contract_sha256(contract_path)
|
||||||
|
acceptance = load_acceptance(contract_path, contract)
|
||||||
|
return {
|
||||||
|
"approval": f"APPROVE S1-OFFSITE-CONTRACT-{digest}",
|
||||||
|
"contract_sha256": digest,
|
||||||
|
"owner_repo": contract["owner_repo"],
|
||||||
|
"owner_status": acceptance["status"],
|
||||||
|
"route_id": contract["route_id"],
|
||||||
|
"status": "review-required" if acceptance["status"] != "accepted" else "accepted",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _bundle_members(validated: dict[str, Any]) -> list[Path]:
|
||||||
|
bundle = validated["bundle"]
|
||||||
|
names = ["manifest.json", "receipt.json"] + sorted(
|
||||||
|
item["filename"] for item in validated["manifest"]["artifacts"]
|
||||||
|
)
|
||||||
|
if set(names) != EXPECTED_MEMBERS:
|
||||||
|
raise OffsiteError("bundle does not match the reviewed off-site envelope membership")
|
||||||
|
return [bundle / name for name in names]
|
||||||
|
|
||||||
|
|
||||||
|
def build_envelope(bundle: Path, backup_spec: Path, destination: Path) -> dict[str, Any]:
|
||||||
|
validated = validate_bundle(bundle, backup_spec)
|
||||||
|
with tarfile.open(destination, "w", format=tarfile.USTAR_FORMAT) as archive:
|
||||||
|
for source in _bundle_members(validated):
|
||||||
|
info = tarfile.TarInfo(source.name)
|
||||||
|
info.size = source.stat().st_size
|
||||||
|
info.mode = 0o600
|
||||||
|
info.mtime = 0
|
||||||
|
info.uid = 0
|
||||||
|
info.gid = 0
|
||||||
|
info.uname = ""
|
||||||
|
info.gname = ""
|
||||||
|
with source.open("rb") as handle:
|
||||||
|
archive.addfile(info, handle)
|
||||||
|
return {
|
||||||
|
"bundle_id": validated["manifest"]["bundle_id"],
|
||||||
|
"envelope_sha256": _sha256(destination),
|
||||||
|
"manifest_sha256": _sha256(validated["bundle"] / "manifest.json"),
|
||||||
|
"size_bytes": destination.stat().st_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def plan_upload(
|
||||||
|
*, bundle: Path, backup_spec: Path, contract_path: Path, fixture_http: bool = False
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
contract = load_contract(contract_path, fixture_http=fixture_http)
|
||||||
|
acceptance = load_acceptance(contract_path, contract)
|
||||||
|
with tempfile.TemporaryDirectory(prefix="s1-offsite-plan-") as temporary:
|
||||||
|
envelope = Path(temporary) / "envelope.tar"
|
||||||
|
metadata = build_envelope(bundle, backup_spec, envelope)
|
||||||
|
object_name = contract["envelope"]["object_name_template"].format(
|
||||||
|
bundle_id=metadata["bundle_id"]
|
||||||
|
)
|
||||||
|
material = "\n".join(
|
||||||
|
(
|
||||||
|
contract_sha256(contract_path),
|
||||||
|
metadata["bundle_id"],
|
||||||
|
metadata["envelope_sha256"],
|
||||||
|
str(metadata["size_bytes"]),
|
||||||
|
contract["provider"]["remote_prefix"],
|
||||||
|
object_name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
exact = hashlib.sha256(material.encode()).hexdigest()[:20].upper()
|
||||||
|
return {
|
||||||
|
**metadata,
|
||||||
|
"approval": f"UPLOAD-S1-OFFSITE-{exact}" if acceptance["status"] == "accepted" else None,
|
||||||
|
"contract_sha256": contract_sha256(contract_path),
|
||||||
|
"executable": acceptance["status"] == "accepted",
|
||||||
|
"object_name": object_name,
|
||||||
|
"owner_status": acceptance["status"],
|
||||||
|
"remote_object_identity": f"{contract['provider']['remote_prefix']}/{object_name}",
|
||||||
|
"route_id": contract["route_id"],
|
||||||
|
"status": "approval-required" if acceptance["status"] == "accepted" else "owner-review-required",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _write_receipt(receipt_dir: Path, payload: dict[str, Any]) -> Path:
|
||||||
|
receipt_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||||
|
final = receipt_dir / f"{payload['bundle_id']}.offsite.json"
|
||||||
|
if final.exists():
|
||||||
|
raise OffsiteError("an off-site receipt already exists for this bundle")
|
||||||
|
descriptor, temporary = tempfile.mkstemp(prefix=".offsite-receipt-", dir=receipt_dir)
|
||||||
|
try:
|
||||||
|
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
||||||
|
json.dump(payload, handle, indent=2, sort_keys=True)
|
||||||
|
handle.write("\n")
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
os.chmod(temporary, 0o600)
|
||||||
|
os.replace(temporary, final)
|
||||||
|
return final
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
os.unlink(temporary)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def upload(
|
||||||
|
*,
|
||||||
|
bundle: Path,
|
||||||
|
backup_spec: Path,
|
||||||
|
contract_path: Path,
|
||||||
|
approval: str,
|
||||||
|
receipt_dir: Path | None = None,
|
||||||
|
fixture_http: bool = False,
|
||||||
|
) -> Path:
|
||||||
|
contract = load_contract(contract_path, fixture_http=fixture_http)
|
||||||
|
acceptance = load_acceptance(contract_path, contract)
|
||||||
|
if acceptance["status"] != "accepted":
|
||||||
|
raise OffsiteError("railiance-platform has not accepted the current upload contract")
|
||||||
|
plan = plan_upload(
|
||||||
|
bundle=bundle,
|
||||||
|
backup_spec=backup_spec,
|
||||||
|
contract_path=contract_path,
|
||||||
|
fixture_http=fixture_http,
|
||||||
|
)
|
||||||
|
if approval != plan["approval"]:
|
||||||
|
raise OffsiteError("upload approval does not match the current exact envelope")
|
||||||
|
secret_env = contract["transport"]["secret_env"]
|
||||||
|
token = os.environ.get(secret_env)
|
||||||
|
if not token or any(character in token for character in "\r\n"):
|
||||||
|
raise OffsiteError(f"{secret_env} must be injected into the contained process")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix="s1-offsite-upload-") as temporary:
|
||||||
|
envelope = Path(temporary) / plan["object_name"]
|
||||||
|
observed = build_envelope(bundle, backup_spec, envelope)
|
||||||
|
if observed["envelope_sha256"] != plan["envelope_sha256"]:
|
||||||
|
raise OffsiteError("envelope changed after approval")
|
||||||
|
segments = (
|
||||||
|
contract["provider"]["base_url"].rstrip("/"),
|
||||||
|
urllib.parse.quote(token, safe=""),
|
||||||
|
urllib.parse.quote(contract["provider"]["remote_prefix"], safe=""),
|
||||||
|
urllib.parse.quote(plan["object_name"], safe=""),
|
||||||
|
)
|
||||||
|
destination = "/".join(segments)
|
||||||
|
authorization = base64.b64encode(f"{token}:".encode()).decode()
|
||||||
|
request = urllib.request.Request(destination, data=envelope.read_bytes(), method="PUT")
|
||||||
|
request.add_header("Authorization", f"Basic {authorization}")
|
||||||
|
request.add_header("Content-Type", contract["transport"]["content_type"])
|
||||||
|
request.add_header("If-None-Match", contract["transport"]["if_none_match"])
|
||||||
|
try:
|
||||||
|
opener = urllib.request.build_opener(_NoRedirect)
|
||||||
|
with opener.open(request, timeout=600) as response:
|
||||||
|
status = response.status
|
||||||
|
etag = response.headers.get("ETag")
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
if exc.code in {409, 412}:
|
||||||
|
raise OffsiteError("remote object collision; no success receipt was written") from exc
|
||||||
|
raise OffsiteError(f"off-site PUT failed with HTTP {exc.code}; response suppressed") from exc
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
raise OffsiteError("off-site PUT failed; endpoint and response suppressed") from exc
|
||||||
|
if status not in contract["transport"]["success_codes"]:
|
||||||
|
raise OffsiteError(f"off-site PUT returned unexpected HTTP {status}")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"receipt_id": str(uuid.uuid4()),
|
||||||
|
"event_type": "offsite-upload",
|
||||||
|
"status": "pass",
|
||||||
|
"created_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
"bundle_id": plan["bundle_id"],
|
||||||
|
"manifest_sha256": plan["manifest_sha256"],
|
||||||
|
"envelope_sha256": plan["envelope_sha256"],
|
||||||
|
"size_bytes": plan["size_bytes"],
|
||||||
|
"remote_object_identity": plan["remote_object_identity"],
|
||||||
|
"route_id": contract["route_id"],
|
||||||
|
"contract_sha256": plan["contract_sha256"],
|
||||||
|
"owner_decision_id": acceptance["decision_id"],
|
||||||
|
"http_status": status,
|
||||||
|
"etag_sha256": hashlib.sha256(etag.encode()).hexdigest() if etag else None,
|
||||||
|
}
|
||||||
|
root = Path(receipt_dir or contract["receipt_root"])
|
||||||
|
return _write_receipt(root, payload)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_prune_receipt(bundle: Path, backup_spec: Path, receipt_path: Path) -> dict[str, Any]:
|
||||||
|
validated = validate_bundle(bundle, backup_spec)
|
||||||
|
try:
|
||||||
|
payload = json.loads(receipt_path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
|
raise OffsiteError("off-site receipt is unreadable") from exc
|
||||||
|
required = {
|
||||||
|
"bundle_id",
|
||||||
|
"contract_sha256",
|
||||||
|
"created_at",
|
||||||
|
"envelope_sha256",
|
||||||
|
"etag_sha256",
|
||||||
|
"event_type",
|
||||||
|
"http_status",
|
||||||
|
"manifest_sha256",
|
||||||
|
"owner_decision_id",
|
||||||
|
"receipt_id",
|
||||||
|
"remote_object_identity",
|
||||||
|
"route_id",
|
||||||
|
"schema_version",
|
||||||
|
"size_bytes",
|
||||||
|
"status",
|
||||||
|
}
|
||||||
|
if not isinstance(payload, dict) or set(payload) != required:
|
||||||
|
raise OffsiteError("off-site receipt fields are invalid")
|
||||||
|
if payload.get("schema_version") != "1.0" or payload.get("event_type") != "offsite-upload":
|
||||||
|
raise OffsiteError("off-site receipt identity is invalid")
|
||||||
|
if payload.get("status") != "pass" or payload.get("bundle_id") != bundle.name:
|
||||||
|
raise OffsiteError("off-site receipt is not passing for this bundle")
|
||||||
|
if payload.get("route_id") != "railiance-backup-offsite-lane":
|
||||||
|
raise OffsiteError("off-site receipt uses the wrong governed route")
|
||||||
|
if payload.get("manifest_sha256") != _sha256(bundle / "manifest.json"):
|
||||||
|
raise OffsiteError("off-site receipt does not bind the current manifest")
|
||||||
|
if not SHA256_RE.fullmatch(str(payload.get("envelope_sha256"))):
|
||||||
|
raise OffsiteError("off-site envelope digest is invalid")
|
||||||
|
if payload.get("http_status") not in {201, 204}:
|
||||||
|
raise OffsiteError("off-site receipt has no accepted PUT status")
|
||||||
|
expected_identity = (
|
||||||
|
f"railiance-infra/railiance-infra__{bundle.name}__s1-envelope.tar"
|
||||||
|
)
|
||||||
|
if payload.get("remote_object_identity") != expected_identity or token_shaped(expected_identity):
|
||||||
|
raise OffsiteError("remote object identity is unsafe to retain")
|
||||||
|
if not SHA256_RE.fullmatch(str(payload.get("contract_sha256"))):
|
||||||
|
raise OffsiteError("off-site contract digest is invalid")
|
||||||
|
etag_digest = payload.get("etag_sha256")
|
||||||
|
if etag_digest is not None and not SHA256_RE.fullmatch(str(etag_digest)):
|
||||||
|
raise OffsiteError("off-site ETag digest is invalid")
|
||||||
|
if not isinstance(payload.get("size_bytes"), int) or not 0 < payload["size_bytes"] <= 536870912:
|
||||||
|
raise OffsiteError("off-site envelope size is invalid")
|
||||||
|
try:
|
||||||
|
datetime.strptime(payload["created_at"], "%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
except (KeyError, TypeError, ValueError) as exc:
|
||||||
|
raise OffsiteError("off-site receipt timestamp is invalid") from exc
|
||||||
|
try:
|
||||||
|
uuid.UUID(str(payload.get("receipt_id")))
|
||||||
|
uuid.UUID(str(payload.get("owner_decision_id")))
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise OffsiteError("off-site receipt decision identities are invalid") from exc
|
||||||
|
if payload["manifest_sha256"] != _sha256(validated["bundle"] / "manifest.json"):
|
||||||
|
raise OffsiteError("off-site receipt manifest validation failed")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def token_shaped(value: str) -> bool:
|
||||||
|
lowered = value.lower()
|
||||||
|
return any(marker in lowered for marker in ("token=", "password=", "authorization", "filesdrop/"))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--contract", type=Path, default=ROOT / "spec/s1-offsite.yaml")
|
||||||
|
parser.add_argument("--backup-spec", type=Path, default=ROOT / "spec/s1-backup.yaml")
|
||||||
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||||
|
subparsers.add_parser("review", help="render the exact owner contract approval")
|
||||||
|
plan_parser = subparsers.add_parser("plan", help="plan one immutable envelope without credentials")
|
||||||
|
plan_parser.add_argument("bundle", type=Path)
|
||||||
|
upload_parser = subparsers.add_parser("upload", help="upload one approved immutable envelope")
|
||||||
|
upload_parser.add_argument("bundle", type=Path)
|
||||||
|
upload_parser.add_argument("--approval", required=True)
|
||||||
|
upload_parser.add_argument("--receipt-dir", type=Path)
|
||||||
|
args = parser.parse_args()
|
||||||
|
try:
|
||||||
|
if args.command == "review":
|
||||||
|
result = review_contract(args.contract)
|
||||||
|
elif args.command == "plan":
|
||||||
|
result = plan_upload(
|
||||||
|
bundle=args.bundle,
|
||||||
|
backup_spec=args.backup_spec,
|
||||||
|
contract_path=args.contract,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
receipt = upload(
|
||||||
|
bundle=args.bundle,
|
||||||
|
backup_spec=args.backup_spec,
|
||||||
|
contract_path=args.contract,
|
||||||
|
approval=args.approval,
|
||||||
|
receipt_dir=args.receipt_dir,
|
||||||
|
)
|
||||||
|
result = {"receipt": str(receipt), "status": "pass"}
|
||||||
|
print(json.dumps(result, sort_keys=True))
|
||||||
|
return 0
|
||||||
|
except (BackupContractError, RestoreError, OffsiteError, OSError, tarfile.TarError) as exc:
|
||||||
|
print(f"S1 off-site operation failed closed: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -10,6 +10,10 @@ retention:
|
||||||
max_total_bytes: 268435456
|
max_total_bytes: 268435456
|
||||||
freshness_hours: 26
|
freshness_hours: 26
|
||||||
|
|
||||||
|
offsite:
|
||||||
|
required_before_prune: true
|
||||||
|
receipt_root: /opt/backup/railiance/infra/offsite-receipts
|
||||||
|
|
||||||
artifacts:
|
artifacts:
|
||||||
os-config:
|
os-config:
|
||||||
format: tar.gz
|
format: tar.gz
|
||||||
|
|
|
||||||
6
spec/s1-offsite-owner-acceptance.yaml
Normal file
6
spec/s1-offsite-owner-acceptance.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
schema_version: "1.0"
|
||||||
|
contract_sha256: pending
|
||||||
|
status: pending
|
||||||
|
owner_repo: railiance-platform
|
||||||
|
decision_id: null
|
||||||
|
accepted_at: null
|
||||||
30
spec/s1-offsite.yaml
Normal file
30
spec/s1-offsite.yaml
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
schema_version: "1.0"
|
||||||
|
|
||||||
|
route_id: railiance-backup-offsite-lane
|
||||||
|
owner_repo: railiance-platform
|
||||||
|
owner_acceptance_file: spec/s1-offsite-owner-acceptance.yaml
|
||||||
|
|
||||||
|
provider:
|
||||||
|
kind: nextcloud-file-drop
|
||||||
|
base_url: https://nx4069.your-storageshare.de/public.php/dav/filesdrop
|
||||||
|
remote_prefix: railiance-infra
|
||||||
|
|
||||||
|
transport:
|
||||||
|
method: PUT
|
||||||
|
content_type: application/x-tar
|
||||||
|
if_none_match: "*"
|
||||||
|
success_codes: [201, 204]
|
||||||
|
authentication: basic-token-empty-password
|
||||||
|
secret_env: RAILIANCE_BACKUP_NC_TOKEN
|
||||||
|
response_body: suppressed
|
||||||
|
|
||||||
|
envelope:
|
||||||
|
format: deterministic-ustar
|
||||||
|
members:
|
||||||
|
- manifest.json
|
||||||
|
- receipt.json
|
||||||
|
- os-config.tar.gz.age
|
||||||
|
- packages.txt.age
|
||||||
|
object_name_template: "railiance-infra__{bundle_id}__s1-envelope.tar"
|
||||||
|
|
||||||
|
receipt_root: /opt/backup/railiance/infra/offsite-receipts
|
||||||
|
|
@ -1,13 +1,18 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
|
import base64
|
||||||
|
import http.server
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tarfile
|
import tarfile
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import threading
|
||||||
import unittest
|
import unittest
|
||||||
|
import uuid
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
@ -26,6 +31,21 @@ from s1_backup import ( # noqa: E402
|
||||||
)
|
)
|
||||||
from s1_backup_contract import BackupContractError, load_spec, validate_spec # noqa: E402
|
from s1_backup_contract import BackupContractError, load_spec, validate_spec # noqa: E402
|
||||||
from s1_restore import RestoreError, _safe_member, extract_bundle, validate_bundle # noqa: E402
|
from s1_restore import RestoreError, _safe_member, extract_bundle, validate_bundle # noqa: E402
|
||||||
|
from s1_offsite import ( # noqa: E402
|
||||||
|
OffsiteError,
|
||||||
|
contract_sha256,
|
||||||
|
plan_upload,
|
||||||
|
review_contract,
|
||||||
|
upload,
|
||||||
|
validate_prune_receipt,
|
||||||
|
)
|
||||||
|
|
||||||
|
EXPECTED_OFFSITE_MEMBERS = {
|
||||||
|
"manifest.json",
|
||||||
|
"receipt.json",
|
||||||
|
"os-config.tar.gz.age",
|
||||||
|
"packages.txt.age",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@unittest.skipUnless(shutil.which("age") and shutil.which("age-keygen"), "age tools required")
|
@unittest.skipUnless(shutil.which("age") and shutil.which("age-keygen"), "age tools required")
|
||||||
|
|
@ -207,6 +227,7 @@ class S1BackupRecoveryTests(unittest.TestCase):
|
||||||
def test_prune_requires_current_exact_approval(self) -> None:
|
def test_prune_requires_current_exact_approval(self) -> None:
|
||||||
declaration = yaml.safe_load(self.spec.read_text())
|
declaration = yaml.safe_load(self.spec.read_text())
|
||||||
declaration["retention"]["keep_complete_bundles"] = 2
|
declaration["retention"]["keep_complete_bundles"] = 2
|
||||||
|
declaration["offsite"]["required_before_prune"] = False
|
||||||
self.spec.write_text(yaml.safe_dump(declaration, sort_keys=False))
|
self.spec.write_text(yaml.safe_dump(declaration, sort_keys=False))
|
||||||
for index in range(3):
|
for index in range(3):
|
||||||
self._create(timestamp=f"2026082{index + 1}T120000Z")
|
self._create(timestamp=f"2026082{index + 1}T120000Z")
|
||||||
|
|
@ -219,6 +240,17 @@ class S1BackupRecoveryTests(unittest.TestCase):
|
||||||
self.assertEqual("pruned", result["status"])
|
self.assertEqual("pruned", result["status"])
|
||||||
self.assertFalse((self.output / plan["candidates"][0]).exists())
|
self.assertFalse((self.output / plan["candidates"][0]).exists())
|
||||||
|
|
||||||
|
def test_prune_blocks_without_offsite_evidence(self) -> None:
|
||||||
|
declaration = yaml.safe_load(self.spec.read_text())
|
||||||
|
declaration["retention"]["keep_complete_bundles"] = 2
|
||||||
|
self.spec.write_text(yaml.safe_dump(declaration, sort_keys=False))
|
||||||
|
for index in range(3):
|
||||||
|
self._create(timestamp=f"2026082{index + 1}T120000Z")
|
||||||
|
plan = plan_prune(self.spec, self.output)
|
||||||
|
self.assertEqual("blocked-missing-offsite-evidence", plan["status"])
|
||||||
|
self.assertIsNone(plan["approval"])
|
||||||
|
self.assertEqual(["s1-backup-20260821T120000Z"], plan["missing_offsite_evidence"])
|
||||||
|
|
||||||
def test_status_rejects_tampered_passing_receipt(self) -> None:
|
def test_status_rejects_tampered_passing_receipt(self) -> None:
|
||||||
bundle = self._create()
|
bundle = self._create()
|
||||||
receipt_path = bundle / "receipt.json"
|
receipt_path = bundle / "receipt.json"
|
||||||
|
|
@ -251,6 +283,187 @@ class S1BackupRecoveryTests(unittest.TestCase):
|
||||||
bootstrap = (ROOT / "ansible/playbooks/bootstrap.yaml").read_text()
|
bootstrap = (ROOT / "ansible/playbooks/bootstrap.yaml").read_text()
|
||||||
self.assertNotIn("s1_backup", bootstrap)
|
self.assertNotIn("s1_backup", bootstrap)
|
||||||
|
|
||||||
|
def _offsite_contract(self, base_url: str, *, accepted: bool) -> Path:
|
||||||
|
contract = yaml.safe_load((ROOT / "spec/s1-offsite.yaml").read_text())
|
||||||
|
contract["provider"]["base_url"] = base_url
|
||||||
|
contract_path = self.spec_dir / "s1-offsite.yaml"
|
||||||
|
contract_path.write_text(yaml.safe_dump(contract, sort_keys=False))
|
||||||
|
acceptance = {
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"contract_sha256": contract_sha256(contract_path) if accepted else "pending",
|
||||||
|
"status": "accepted" if accepted else "pending",
|
||||||
|
"owner_repo": "railiance-platform",
|
||||||
|
"decision_id": str(uuid.uuid4()) if accepted else None,
|
||||||
|
"accepted_at": "2026-08-23T12:00:00Z" if accepted else None,
|
||||||
|
}
|
||||||
|
(self.spec_dir / "s1-offsite-owner-acceptance.yaml").write_text(
|
||||||
|
yaml.safe_dump(acceptance, sort_keys=False)
|
||||||
|
)
|
||||||
|
return contract_path
|
||||||
|
|
||||||
|
def test_offsite_contract_is_pending_owner_review(self) -> None:
|
||||||
|
review = review_contract(ROOT / "spec/s1-offsite.yaml")
|
||||||
|
self.assertEqual("review-required", review["status"])
|
||||||
|
self.assertTrue(review["approval"].startswith("APPROVE S1-OFFSITE-CONTRACT-"))
|
||||||
|
|
||||||
|
def test_deterministic_single_object_offsite_upload_and_collision(self) -> None:
|
||||||
|
token = "fixture-upload-token"
|
||||||
|
objects: dict[str, bytes] = {}
|
||||||
|
|
||||||
|
class Handler(http.server.BaseHTTPRequestHandler):
|
||||||
|
def do_PUT(self) -> None: # noqa: N802
|
||||||
|
expected_auth = "Basic " + base64.b64encode(f"{token}:".encode()).decode()
|
||||||
|
if self.headers.get("Authorization") != expected_auth:
|
||||||
|
self.send_response(401)
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
if self.headers.get("If-None-Match") != "*":
|
||||||
|
self.send_response(428)
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
if self.path in objects:
|
||||||
|
self.send_response(412)
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
length = int(self.headers["Content-Length"])
|
||||||
|
objects[self.path] = self.rfile.read(length)
|
||||||
|
self.send_response(201)
|
||||||
|
self.send_header("ETag", '"fixture-etag"')
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def log_message(self, format: str, *args: object) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
try:
|
||||||
|
bundle = self._create()
|
||||||
|
contract = self._offsite_contract(
|
||||||
|
f"http://127.0.0.1:{server.server_port}/filesdrop", accepted=True
|
||||||
|
)
|
||||||
|
plan = plan_upload(
|
||||||
|
bundle=bundle,
|
||||||
|
backup_spec=self.spec,
|
||||||
|
contract_path=contract,
|
||||||
|
fixture_http=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(plan["executable"])
|
||||||
|
second_plan = plan_upload(
|
||||||
|
bundle=bundle,
|
||||||
|
backup_spec=self.spec,
|
||||||
|
contract_path=contract,
|
||||||
|
fixture_http=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(plan["envelope_sha256"], second_plan["envelope_sha256"])
|
||||||
|
receipt_dir = self.root / "offsite-receipts"
|
||||||
|
with mock.patch.dict(os.environ, {"RAILIANCE_BACKUP_NC_TOKEN": token}):
|
||||||
|
with self.assertRaisesRegex(OffsiteError, "exact envelope"):
|
||||||
|
upload(
|
||||||
|
bundle=bundle,
|
||||||
|
backup_spec=self.spec,
|
||||||
|
contract_path=contract,
|
||||||
|
approval="UPLOAD-S1-OFFSITE-WRONG",
|
||||||
|
receipt_dir=receipt_dir,
|
||||||
|
fixture_http=True,
|
||||||
|
)
|
||||||
|
self.assertEqual({}, objects)
|
||||||
|
receipt_path = upload(
|
||||||
|
bundle=bundle,
|
||||||
|
backup_spec=self.spec,
|
||||||
|
contract_path=contract,
|
||||||
|
approval=plan["approval"],
|
||||||
|
receipt_dir=receipt_dir,
|
||||||
|
fixture_http=True,
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(OffsiteError, "collision"):
|
||||||
|
upload(
|
||||||
|
bundle=bundle,
|
||||||
|
backup_spec=self.spec,
|
||||||
|
contract_path=contract,
|
||||||
|
approval=plan["approval"],
|
||||||
|
receipt_dir=receipt_dir,
|
||||||
|
fixture_http=True,
|
||||||
|
)
|
||||||
|
receipt = validate_prune_receipt(bundle, self.spec, receipt_path)
|
||||||
|
self.assertEqual(plan["remote_object_identity"], receipt["remote_object_identity"])
|
||||||
|
self.assertNotIn(token, receipt_path.read_text())
|
||||||
|
self.assertEqual(1, len(objects))
|
||||||
|
envelope_path = self.root / "observed-envelope.tar"
|
||||||
|
envelope_path.write_bytes(next(iter(objects.values())))
|
||||||
|
with tarfile.open(envelope_path, "r:") as archive:
|
||||||
|
self.assertEqual(EXPECTED_OFFSITE_MEMBERS, {item.name for item in archive})
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
thread.join(timeout=2)
|
||||||
|
|
||||||
|
def test_offsite_upload_refuses_pending_owner_and_wrong_approval(self) -> None:
|
||||||
|
bundle = self._create()
|
||||||
|
contract = self._offsite_contract("http://127.0.0.1:9/filesdrop", accepted=False)
|
||||||
|
plan = plan_upload(
|
||||||
|
bundle=bundle,
|
||||||
|
backup_spec=self.spec,
|
||||||
|
contract_path=contract,
|
||||||
|
fixture_http=True,
|
||||||
|
)
|
||||||
|
self.assertFalse(plan["executable"])
|
||||||
|
self.assertIsNone(plan["approval"])
|
||||||
|
with self.assertRaisesRegex(OffsiteError, "has not accepted"):
|
||||||
|
upload(
|
||||||
|
bundle=bundle,
|
||||||
|
backup_spec=self.spec,
|
||||||
|
contract_path=contract,
|
||||||
|
approval="UPLOAD-S1-OFFSITE-WRONG",
|
||||||
|
receipt_dir=self.root / "receipts",
|
||||||
|
fixture_http=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_offsite_upload_never_follows_redirect(self) -> None:
|
||||||
|
paths: list[str] = []
|
||||||
|
|
||||||
|
class RedirectHandler(http.server.BaseHTTPRequestHandler):
|
||||||
|
def do_PUT(self) -> None: # noqa: N802
|
||||||
|
paths.append(self.path)
|
||||||
|
self.send_response(307)
|
||||||
|
self.send_header("Location", "/credential-capture")
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def log_message(self, format: str, *args: object) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), RedirectHandler)
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
try:
|
||||||
|
bundle = self._create()
|
||||||
|
contract = self._offsite_contract(
|
||||||
|
f"http://127.0.0.1:{server.server_port}/filesdrop", accepted=True
|
||||||
|
)
|
||||||
|
plan = plan_upload(
|
||||||
|
bundle=bundle,
|
||||||
|
backup_spec=self.spec,
|
||||||
|
contract_path=contract,
|
||||||
|
fixture_http=True,
|
||||||
|
)
|
||||||
|
with mock.patch.dict(os.environ, {"RAILIANCE_BACKUP_NC_TOKEN": "redirect-token"}):
|
||||||
|
with self.assertRaisesRegex(OffsiteError, "HTTP 307"):
|
||||||
|
upload(
|
||||||
|
bundle=bundle,
|
||||||
|
backup_spec=self.spec,
|
||||||
|
contract_path=contract,
|
||||||
|
approval=plan["approval"],
|
||||||
|
receipt_dir=self.root / "redirect-receipts",
|
||||||
|
fixture_http=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(1, len(paths))
|
||||||
|
self.assertNotEqual("/credential-capture", paths[0])
|
||||||
|
self.assertFalse((self.root / "redirect-receipts").exists())
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
thread.join(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,7 @@ no timer is installed merely by running a verification command.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RAIL-HO-WP-0012-T05
|
id: RAIL-HO-WP-0012-T05
|
||||||
status: wait
|
status: progress
|
||||||
priority: high
|
priority: high
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -142,6 +142,25 @@ dry-run proves object naming and collision behavior, a controlled upload is
|
||||||
visible through owner-provided metadata, and local/off-host retention cannot
|
visible through owner-provided metadata, and local/off-host retention cannot
|
||||||
delete the only recoverable copy.
|
delete the only recoverable copy.
|
||||||
|
|
||||||
|
**Source preparation (2026-08-23):** `spec/s1-offsite.yaml` and
|
||||||
|
`scripts/s1_offsite.py` define a pending-owner contract that wraps the complete
|
||||||
|
encrypted bundle and metadata into one deterministic immutable object. The
|
||||||
|
upload uses `If-None-Match: *`, accepts only a freshly rendered exact envelope
|
||||||
|
approval, suppresses endpoint/response data, and emits a local metadata-only
|
||||||
|
receipt. Production pruning now refuses candidates without a receipt bound to
|
||||||
|
the current manifest. A disposable WebDAV fixture proves successful upload,
|
||||||
|
collision and redirect refusal, pending-owner refusal, and absence of the
|
||||||
|
injected token from retained evidence.
|
||||||
|
|
||||||
|
Owner review remains mandatory. The exact review digest is rendered by
|
||||||
|
`make s1-offsite-review`; `spec/s1-offsite-owner-acceptance.yaml` remains
|
||||||
|
`pending`, so no live upload can execute. Review of the owner implementation
|
||||||
|
also found a tracked secret-shaped default in its Forgejo backup script. Its
|
||||||
|
value was not copied or recorded; `railiance-platform` must confirm it is
|
||||||
|
retired or rotated before accepting this contract. The route's missing
|
||||||
|
authoritative workload identity keeps this interface attended rather than
|
||||||
|
scheduled.
|
||||||
|
|
||||||
## T06 — Perform an attended isolated restore drill
|
## T06 — Perform an attended isolated restore drill
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
@ -178,13 +197,14 @@ decrypted configuration.
|
||||||
|
|
||||||
## Source delivery record — 2026-08-23
|
## Source delivery record — 2026-08-23
|
||||||
|
|
||||||
- `make validate-s1-backup` passes the declaration and 16 fixture recovery
|
- `make validate-s1-backup` passes the declaration and 21 fixture recovery
|
||||||
tests using disposable age identities and storage.
|
tests using disposable age identities and storage.
|
||||||
- The full repository suite passes 46 tests. The timer calendar is accepted by
|
- The full repository suite passes 51 tests. The timer calendar is accepted by
|
||||||
`systemd-analyze`; deployment YAML and separation from bootstrap are checked
|
`systemd-analyze`; deployment YAML and separation from bootstrap are checked
|
||||||
as source contracts.
|
as source contracts.
|
||||||
- Native `ansible-playbook --syntax-check` was unavailable on the development
|
- Native `ansible-playbook --syntax-check` was unavailable on the development
|
||||||
workstation. The deployment remains unexecuted and gated by
|
workstation. The deployment remains unexecuted and gated by
|
||||||
`DEPLOY-RAIL-HO-WP-0012-S1-BACKUP-TIMER` plus a clean committed revision.
|
`DEPLOY-RAIL-HO-WP-0012-S1-BACKUP-TIMER` plus a clean committed revision.
|
||||||
- T05 and T06 remain `wait`; no off-host write, retained-artifact deletion,
|
- T05 is source-prepared and pending owner acceptance; T06 remains `wait`. No
|
||||||
private-key access, or live-host restore occurred.
|
off-host write, retained-artifact deletion, private-key access, or live-host
|
||||||
|
restore occurred.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue