From 9349a2de648e241da8e00ba7ae25cb2a877ff3e9 Mon Sep 17 00:00:00 2001 From: Doan Luu Duc Tai Date: Sun, 1 Nov 2026 07:13:45 -0600 Subject: feat(guix): carry prebuilt runtime and recover preserved state --- README.txt | 8 +- docs/CHAT-PRODUCTION.txt | 4 +- docs/GUIX.txt | 207 ++++++++++++++++++++++++++++++++++ docs/MAP.txt | 29 ++++- docs/OPERATIONS.txt | 7 +- docs/POLICY.txt | 4 + infra/guix/bootstrap.sh | 86 ++++++++++++++ infra/guix/build.sh | 37 ++++++ infra/guix/bundle.sh | 56 +++++++++ infra/guix/containers.conf | 24 ++++ infra/guix/manifest.scm | 19 ++++ infra/guix/restore.sh | 90 +++++++++++++++ infra/guix/run.sh | 45 ++++++++ infra/guix/test-bootstrap.sh | 118 +++++++++++++++++++ infra/guix/test-recovery.sh | 133 ++++++++++++++++++++++ infra/guix/test.sh | 166 +++++++++++++++++++++++++++ infra/stack.service | 4 +- src/app.rs | 44 +++++--- src/app/archive.rs | 263 +++++++++++++++++++++++++++++++++++++++++-- src/app/cli.rs | 21 +++- src/app/files.rs | 77 +++++++++++++ src/app/images.rs | 29 ++++- src/app/release.rs | 2 + 23 files changed, 1427 insertions(+), 46 deletions(-) create mode 100644 docs/GUIX.txt create mode 100644 infra/guix/bootstrap.sh create mode 100644 infra/guix/build.sh create mode 100644 infra/guix/bundle.sh create mode 100644 infra/guix/containers.conf create mode 100644 infra/guix/manifest.scm create mode 100644 infra/guix/restore.sh create mode 100644 infra/guix/run.sh create mode 100644 infra/guix/test-bootstrap.sh create mode 100644 infra/guix/test-recovery.sh create mode 100644 infra/guix/test.sh diff --git a/README.txt b/README.txt index c680f43..ab5a3bd 100644 --- a/README.txt +++ b/README.txt @@ -22,7 +22,7 @@ STACK START LOCAL - Linux tools: rustup, Podman, Git, GnuPG, Node >=22.18.0, npm. + Linux tools: rustup, Podman, Git, GnuPG, Node >=22.18.0, npm, bubblewrap. Native chat checks: GCC-compatible C compiler, Linux UAPI headers, libclang, pkg-config, make, Perl, curl, tar, OpenSSL >=3.5 development libraries. Pinned Rust includes rustfmt, Clippy, static x86_64 Linux target. @@ -75,3 +75,9 @@ CHAT — EXPERIMENTAL Packaged HTTPS, owner setup, recovery: docs/CHAT-PRODUCTION.txt. SQLite snapshots use bundled SQLite C library. Static releases use OpenSSL, hash-verified musl headers, Rust musl target. VPS needs no compiler. + +GUIX — EXPERIMENTAL + + Single .tar.gz capsule with encrypted private state: docs/GUIX.txt. + Canonical Guix store needs privileged bootstrap; existing managed stores refused. + Real Guix lifecycle/clean-host restore remain unproven. Do not deploy yet. diff --git a/docs/CHAT-PRODUCTION.txt b/docs/CHAT-PRODUCTION.txt index fdb48a6..2507e07 100644 --- a/docs/CHAT-PRODUCTION.txt +++ b/docs/CHAT-PRODUCTION.txt @@ -106,8 +106,8 @@ DEPLOY AND CREATE THE REAL OWNER FILE firewall rules. Caddy obtains and renews public certificates automatically. Public certificate validation requires standard reachable ACME ports. - The systemd user service runs ./bin/stack up --production at boot and - ./bin/stack down on stop. Podman restarts + The supplied systemd user service uses ./bin/run for up --production and down; + the launcher selects an included Guix runtime when present. Podman restarts failed containers. Complete matching-architecture releases need no compilation or registry pull. Check all four services after activation: diff --git a/docs/GUIX.txt b/docs/GUIX.txt new file mode 100644 index 0000000..05aa572 --- /dev/null +++ b/docs/GUIX.txt @@ -0,0 +1,207 @@ +Copyright (C) 2026 Doan Luu Duc Tai + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +GUIX RECOVERY + + Goal: one .tar.gz, unpack/bootstrap, one command restores and starts services. + Private state is GPG-encrypted inside the tarball. Bootstrap, binaries and + public release/source are plaintext. Outer tarball is not encrypted or signed. + Trust its origin; SHA256SUMS detects corruption, not malicious replacement. + Keep decryption key/passphrase separately; never send them through chat. + + Guix supplies Podman/tools. Existing Caddy/cgit/SearXNG/chat OCI applications, + static musl/OpenSSL builds, domains, HTTPS/WKD and crypto remain. + No new Rust/npm dependency, Guix System install, or native service rewrite. + One root .env and public .env.example; databases/TLS/owner/history: var/data. + +BUILD LOCAL + + sh infra/guix/build.sh + cargo run --release --locked -- release + + Needs working Guix daemon, x86_64 Linux and rootless Podman prerequisites. + Channel commits: bin/guix-channels.scm. time-machine builds the manifest's + bin/guix-runtime.tar.gz. Release runs full checks and packages matching source, + binaries, browser assets and saved images. No compilation on recovery host. + Podman info smoke test is not clean-host/container lifecycle proof. + + Ordinary pack uses canonical /gnu/store. Former -RR wrapper creates one-ID + user namespace; privileged helpers cannot map host subordinate IDs there. + Builder's canonical store could bypass relocation and hide this defect. + https://guix.gnu.org/manual/devel/en/html_node/Invoking-guix-pack.html + https://guix.gnu.org/manual/devel/en/html_node/Replicating-Guix.html + https://raw.githubusercontent.com/containers/podman/main/docs/tutorials/rootless_tutorial.md + +MAKE CARRY FILE + + Production: create consistent encrypted export; copy and verify it locally: + + ./bin/stack backup --include-images + + Bundle a trusted freshly checked release with that export: + + sh infra/guix/bundle.sh /path/backup.tar.gz.gpg /path/release.tar.gz /path/recovery.tar.gz + + Bundler executes the trusted release's CLI to validate it with restore + --release. Backup-kind archives, private .env/var/data, missing runtime/chat + artifacts and missing saved images are refused before publication. + CLI, scripts, config, runtime and provenance all come from the same release. + Original release archive is unchanged and includes corresponding source and + LICENSE. Original encrypted backup retains prior source/work and rollback. + Runtime is duplicated outside release for bootstrap simplicity; extra disk + space avoids another installer/format. One carry file remains sufficient. + + Old backups remain supported; release must implement this migration's + --release validation and runtime export. Blindly reusing old management binary + loses Guix files on the next backup. Restore now activates matching new source + and binary, including canonical infra/guix/run.sh needed by later backups. + +BOOTSTRAP HOST; RESTORE + + Fresh x86_64 Linux: trusted shell, GNU coreutils/tar/gzip/findutils/diffutils, + awk, util-linux flock, sudo/root access. Bootstrap refuses /var/guix and foreign + /gnu trees. Installs only /gnu/store entries; never host /bin or /etc links. + + tar -xzf recovery.tar.gz + sudo ./stack-recovery/bootstrap + + Root-owned non-writable closure entries are staged on /gnu's filesystem, + published without overwriting, then recorded by pack digest. Retry reconciles + unfinished installs; later packs retain old entries. Root install records and + lock: /gnu/.stack-runtimes. Target needs no Guix daemon/downloads. Existing + managed Guix hosts need separate planning; do not merge unmanaged store items. + + Administrator also supplies privileged newuidmap/newgidmap, >=65536 subordinate + UIDs/GIDs, user namespaces, writable login XDG_RUNTIME_DIR, low-port sysctl + net.ipv4.ip_unprivileged_port_start <=80. DNS/firewall/ports remain host duties. + OPERATIONS.txt Debian setup installs distro Podman too: existing fallback, + not evidence of a Guix-only target. Service user imports GPG key locally. + + ./stack-recovery/restore /home/ops/stack + + New destination required. Capsule checks hashes/bootstrap/prerequisites, + decrypts through Rust manifest/path validation, requires saved backup images, + then activates its paired release under the capsule's Guix runtime/config. + Activation installs new public source/binaries/images, preserves .git, .env, + var/data, and starts four services. Source worktree becomes new release; + original source remains in encrypted backup/history. Only tested version + pairs establish application/data compatibility; format-2 integrity cannot. + + Failed activation retains data plus private var/guix-recovery intent marker. + Fix cause and rerun the same restore command with the same capsule/destination. + Matching backup/release digests permit resume without decrypting/restoring + twice; unrelated destinations are refused. Recovery lock excludes concurrent + activation and is closed in child processes. Success removes pending marker. + Do not replace an interrupted activation with plain up; it cannot finish + copying a release. Completed recoveries use normal management commands: + + /home/ops/stack/bin/run up --production + + Launcher supplies --root and Guix-only CONTAINERS_CONF; do not pass --root. + Runtime expands privately into content-addressed var/guix; old packs remain. + Activation atomically replaces the archive/provenance files, including existing + read-only packs; regression test covers replacement followed by export/restore. + Direct bin/stack and releases without pack use host tools. Guix symlinks remain + inside opaque runtime pack; Rust application extractor still rejects links. + Boot setup remains explicit: systemd user service plus linger, OPERATIONS.txt. + Unit uses bin/run at ~/stack; adapt paths for another destination. + +CHECKS; DEPLOYMENT GATE + + sh infra/guix/test.sh + cargo stack check + + Shell tests need bubblewrap. Synthetic packs test actual bootstrap/filesystem + behavior. Synthetic recovery tests actual orchestration, failed activation, + resume, intent/lock refusal, private sentinels, and child FD closure. Fake tools + do not prove real encryption, image import, services or kernel UID mapping. + Rust tests prove archive-kind refusal before publication and runtime roundtrip. + Actual static CLI also checked synthetic bundle validation/publication/privacy. + No strict Rust/Clippy/Rustdoc, typed ESLint, LibreJS or crypto gate weakened. + Existing documented lint/dependency exceptions remain unchanged. + + Clean-host test exposed missing distro container signature policy. Image load + now supplies a temporary policy: reject by default; accept local Docker/OCI + archives only. Export supplies a separate temporary policy accepting only + containers-storage; registry transport remains rejected. Verified archive + manifests and architecture/layer/image-ID locks + still gate startup. Registry transport is not accepted by this load policy; + global host policy stays unchanged. Policies are generated by AGPL Rust code; + the foreign schema rejects comment/legal metadata, so no headerless source file + is added. The initial failed VM retained decrypted data and pending intent. + + A second clean-host check found legacy backups had collapsed Caddy's /data + and /config into one caddy/ tree. After manifest verification, backup restore + moves credentials into data/caddy and autosave into config/caddy; bytes and + permissions are retained. Conflicting old/new layouts are refused before + publishing a destination. New exports copy /data/. and /config/. separately. + + Before production, in order: + 1. Full checked release with real Guix runtime and source/binary pair. + 2. Fresh host without Guix/distro Podman/Git/GPG: offline bootstrap/restore; + four services, exact images, detached UID/network helpers. + 3. .env count, domains, HTTPS/WKD, original owner fingerprint; unchanged chat + rows and history decryption with both original participant identities. + 4. Backup recovered stack, second recovery, reboot startup. + 5. First host-Podman activation and Guix runtime upgrades. Ordinary direct + activate retains its process PATH; deploy through matching release runtime. + 6. Fresh verified independent production backup; deploy only prebuilt release; + verify public routes/state, retain prior release/runtime and encrypted export. + + Rollback: stop new containers before old runtime binds production ports. + Keep .env/var/data; never delete closure entries used by running processes. + +VERIFIED — 2026-11-01 + + Offline KVM guest: official Debian 12 x86_64 image, no initial /gnu, Guix, + Podman, Git or GPG. Host supplies only documented kernel/account prerequisites. + Network restricted before bootstrap; readonly carry file plus separately + forwarded GPG agent; no private key copied. Bootstrap and one restore command + loaded four saved images and started all services without source builds. + + Strict HTTPS verified all four domains using original certificates; both WKD + routes matched the original key. All 651 immutable snapshot files matched + after legacy Caddy path normalization: 18 active TLS keys, 16 certificates. + All 126 release source/binary files matched. Original .env, owner fingerprint + and every canonical SQLite table matched. Production snapshot contained zero + conversations/messages; original real-message decryption therefore untestable. + Disposable Chromium/Firefox tests verified both participant identities, + 54-message history, encrypted snapshots/recovery and GNU LibreJS 7.21.1. + + Full local release gates passed live npm/Rust advisory checks, strict fmt, + Clippy, Rustdoc, typed ESLint and both TypeScript compilers; no suppressions. + Required OpenSSL 3.6.5 hybrid suites, protected OpenPGP v6 identity, wrong-pass + and tamper controls passed. Synthetic shell tests cover bootstrap collisions, + interrupted recovery/retry, lock exclusion and child descriptor closure. + + Runtime: 500687877 bytes, SHA256: + c5c7b849da152ee7a98b01602a1e91285ce4e3a80021a9cecb5f1e594dfc7810 + Guix channel 230aa373f315f247852ee07dff34146e9b480aec; 113 store entries. + Git 2.52.0, GnuPG 2.4.7, Podman 5.7.0, conmon 2.1.13, crun 1.25.1. + Empty helper lists force PATH lookup, selecting packed conmon/crun. Ordinary + host config stays unchanged. Rootless cleanup handles immutable user-owned + temporary trees without changing symlink targets or the published pack. + https://github.com/containers/podman/blob/v5.7.0/vendor/go.podman.io/common/pkg/config/config.go#L849-L875 + https://github.com/containers/podman/blob/v5.7.0/cmd/podman/images/save.go#L91-L94 + + Deployment gate above still applies to each carried artifact. Retain complete + original worktree, binary/launcher, installed user unit and engine storage + independently before activation. Reload the installed unit after switching to + bin/run. For rollback stop Guix containers first, retain their engine tree, + restore prior engine path, binary/launcher and unit; invoke prior activation + with host PATH and Guix CONTAINERS_CONF overrides unset. Keep current .env and + var/data. An old release alone does not remove a newer Guix launcher/runtime. + Private evidence: var/guix-*.log and .tools/guix-production/. These are ignored + and include private state hashes; do not publish them with public source. diff --git a/docs/MAP.txt b/docs/MAP.txt index 6f5e13d..de546b8 100644 --- a/docs/MAP.txt +++ b/docs/MAP.txt @@ -15,7 +15,7 @@ along with this program. If not, see . FILE MAP - Public tree: every tracked file, including this map. + Public tree: every tracked file plus pending Guix source/docs and this map. Static inventory; update when public paths change. Root: stack/. Private/generated trees below list roles only; contents stay unlisted. @@ -67,6 +67,7 @@ stack/ Personal service source, build recipes and recovery tooling. | |-- CHAT-OPENSSL-MIGRATION.txt OpenSSL crypto migration evidence, dependencies, reviewed exceptions. | |-- CHAT-PRODUCTION.txt Chat release packaging, HTTPS activation, owner setup, production recovery. | |-- CONFIGURATION.txt Supported root settings, validation, public/private state boundaries. +| |-- GUIX.txt Experimental Guix capsule, canonical-store bootstrap, gates and blockers. | |-- LINTING.txt Strict Rust/browser checks, generated-code limits, justified exceptions. | |-- MAP.txt This public-file tree and private/runtime directory roles. | |-- OPERATIONS.txt Stack development, encrypted backup/restore, host setup, deployment. @@ -90,6 +91,17 @@ stack/ Personal service source, build recipes and recovery tooling. | | | `-- supervisord.conf Run nginx and fcgiwrap inside the cgit container. | | `-- chat/ Prebuilt chat image recipe. | | `-- Containerfile Package prebuilt static chat server and web/crypto assets. +| |-- guix/ Experimental Guix runtime and recovery capsule tooling. +| | |-- bootstrap.sh Root-owned canonical closure installation; refuse managed/foreign stores. +| | |-- build.sh Capture Guix channels; build canonical runtime pack; probe Podman info. +| | |-- bundle.sh Validate paired release; wrap encrypted backup, release and bootstrap. +| | |-- containers.conf Guix-only PATH-selected conmon/crun and rootless networking. +| | |-- manifest.scm Guix packages for the portable container/runtime tools. +| | |-- restore.sh Check capsule/host, decrypt safe restore, require images, start production. +| | |-- run.sh Select content-addressed Guix runtime; launch stack with explicit root. +| | |-- test-bootstrap.sh Real bootstrap in isolated filesystem using synthetic closure packs. +| | |-- test-recovery.sh Synthetic release activation, resume, intent/locks and private-state preservation. +| | `-- test.sh Bootstrap, immutable cleanup, integrity, overwrite refusal and launcher fixtures. | |-- searxng/ Search service configuration. | | `-- settings.yml SearXNG defaults: private instance, rate limiting, HTML search. | |-- Caddyfile Production domain routing, HTTPS, WKD and service reverse proxies. @@ -100,11 +112,11 @@ stack/ Personal service source, build recipes and recovery tooling. | `-- stack.service Systemd user boot unit for ~/stack start/stop. |-- src/ Management executable source. | |-- app/ Management command implementation modules. -| | |-- archive.rs Consistent snapshots, encrypted exports and validated directory restores. +| | |-- archive.rs Consistent snapshots, encrypted exports, legacy Caddy layout and validated restores. | | |-- chat.rs Chat build/package artifacts, container settings and owner administration. | | |-- cli.rs Management subcommands, flags and argument validation. -| | |-- files.rs Safe filesystem/archive operations, manifests and hashes. -| | |-- images.rs Export/import exact container images; enforce platform and content locks. +| | |-- files.rs Safe filesystem/archive operations, manifests, hashes and runtime copies. +| | |-- images.rs Export/import saved images with archive-only policy, platform and content locks. | | |-- process.rs Run child processes; capture output and report failures/status. | | |-- project.rs Discover/lock project; settings, Podman commands, service lifecycle. | | |-- release.rs Build checked release archives; validate/install production artifacts. @@ -152,7 +164,7 @@ PRIVATE AND GENERATED PATHS — NOT PUBLIC SOURCE .env Sole private root configuration; never commit. .git/ Local Git objects, refs, hooks and repository settings. .tools/ Downloaded toolchains, build tools, sources and caches. - bin/ Prebuilt management/chat commands and packaged chat assets. + bin/ Prebuilt management/chat commands, launcher and Guix pack. dist/ Locally built release tarballs and checksum files. node_modules/ Locked npm development dependencies; recreated by npm ci. target/ Cargo build output, WASM targets and generated rustdoc. @@ -166,10 +178,15 @@ PRIVATE AND GENERATED PATHS — NOT PUBLIC SOURCE repos/ Served bare Git repositories; separate from root .git/. searxng/ Runtime search configuration and persistent service files. exports/ Encrypted application backups and checksum files. + guix/ Unpacked content-addressed runtime packs; old packs retained. incoming/ Copied release archives and temporary deployment bootstrap. podman/ Recreated container storage/run state; never transfer as backup. graphify/ Public-source graph index, visual map and extraction artifacts. Other var/ paths may hold generated fixtures or temporary work. This map does not enumerate private files. Backups must keep .env and var/data; public Git - alone cannot recover services. See OPERATIONS.txt. + alone cannot recover services. See OPERATIONS.txt and GUIX.txt. + + Experimental host integration: /gnu/store holds canonical Guix packages; + /gnu/.stack-runtimes holds root-owned package install records and lock. + Neither holds application configuration, databases or TLS keys. diff --git a/docs/OPERATIONS.txt b/docs/OPERATIONS.txt index 55dc81c..91ab10b 100644 --- a/docs/OPERATIONS.txt +++ b/docs/OPERATIONS.txt @@ -18,6 +18,7 @@ STACK OPERATIONS Root: ~/stack. Develop on local Linux; production: ops@23.155.44.67. Public source: https://git.doan.luuduct.ai/stack. GNU Affero General Public License, version 3 or later. + Experimental Guix capsule: GUIX.txt. Commands below use host Podman. KEEP @@ -68,7 +69,7 @@ CHECK LOCAL cargo stack check - Checks formatting, all workspace Clippy targets/features, tests, private + Checks Guix shell/bootstrap fixtures (bubblewrap), formatting, workspace Clippy, tests, private rustdoc, advisories, duplicate versions, sources, licenses. cargo-about regenerates THIRD-PARTY-LICENSES.html. Format with cargo fmt --all. Dependency changes: update Cargo.lock deliberately; run full check; commit @@ -184,8 +185,8 @@ NEW PRODUCTION HOST systemctl --user enable --now stack.service systemctl --user status stack.service - Unit assumes ~/stack; edit paths for other location. Uses bin/stack. - Linger keeps user service alive + Unit assumes ~/stack; edit paths for other location. Current unit uses bin/run: + confirm launcher exists before enabling. Linger keeps user service alive without login. Containers use unless-stopped restart policy. Optional former VPS SSH Git path; administrator only: diff --git a/docs/POLICY.txt b/docs/POLICY.txt index dea595b..747b774 100644 --- a/docs/POLICY.txt +++ b/docs/POLICY.txt @@ -32,6 +32,8 @@ PROJECT CONVENTIONS Required legal headers are the exception. Prose uses .txt; generated HTML license page is an explicit exception. Rust doc attributes supply crate metadata without comments. + Temporary container import/export policies are generated protocol JSON: its strict + foreign schema rejects legal metadata. AGPL notice stays in the Rust generator. One root .env holds deployment overrides/configured secrets. Never commit .env or var/. Publish only root .env.example; ignore variants/nested examples. @@ -76,6 +78,8 @@ PORTABILITY packages, subordinate UID/GID allocations, one low-port sysctl, user linger, systemd user-service link. Host SSH keys, firewall policy, accounts, and disks are host administration, excluded from application exports. + Experimental Guix capsule also installs canonical /gnu/store packages and + /gnu/.stack-runtimes install records. Private application state stays in stack/. Releases include static x86_64 Linux management/chat commands, WASM browser crypto, exact corresponding sources, native linux/amd64 container images. diff --git a/infra/guix/bootstrap.sh b/infra/guix/bootstrap.sh new file mode 100644 index 0000000..6f53dbc --- /dev/null +++ b/infra/guix/bootstrap.sh @@ -0,0 +1,86 @@ +#!/bin/sh +# Copyright (C) 2026 Doan Luu Duc Tai +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +set -eu +umask 077 +fail() { printf '%s\n' "$1" >&2; exit 1; } +safe_directory() { + [ -d "$1" ] && [ ! -L "$1" ] && [ "$(stat -c %u "$1")" = 0 ] && + [ -z "$(find "$1" -maxdepth 0 -perm /022 -print)" ] +} +[ "$#" = 0 ] || fail 'Usage: sudo ./stack-recovery/bootstrap' +[ "$(id -u)" = 0 ] || fail 'Run bootstrap explicitly as root; restore runs as the service user.' +[ "$(uname -sm)" = 'Linux x86_64' ] || fail 'Requires x86_64 Linux.' +command -v flock >/dev/null || fail 'Host util-linux flock is required.' +command -v diff >/dev/null || fail 'Host GNU diffutils is required.' +BUNDLE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +(cd "$BUNDLE" && sha256sum --check --strict SHA256SUMS) +[ ! -e /var/guix ] && [ ! -L /var/guix ] || fail 'Managed Guix installation found; refusing bootstrap.' +[ ! -L /gnu ] && [ ! -L /gnu/store ] || fail 'Symlink /gnu or store; refusing bootstrap.' +STAGE= +INITIAL= +trap '[ -z "$STAGE" ] || rm -rf -- "$STAGE"; [ -z "$INITIAL" ] || rm -rf -- "$INITIAL"' EXIT HUP INT TERM +if [ ! -e /gnu ]; then + INITIAL=$(mktemp -d /.stack-guix.XXXXXX) + chmod 755 "$INITIAL" + mkdir -m 755 "$INITIAL/store" "$INITIAL/.stack-runtimes" + mv -T --no-clobber -- "$INITIAL" /gnu + rm -rf -- "$INITIAL" + INITIAL= +fi +safe_directory /gnu && safe_directory /gnu/.stack-runtimes || fail 'Foreign or writable /gnu ownership metadata; refusing bootstrap.' +exec 9>/gnu/.stack-runtimes/install.lock +flock 9 +if [ -e /gnu/store ]; then + safe_directory /gnu/store || fail 'Foreign or writable Guix store; refusing bootstrap.' +else + [ ! -L /gnu/store ] || fail 'Symlink store; refusing bootstrap.' + mkdir -m 755 /gnu/store +fi +DIGEST=$(sha256sum "$BUNDLE/runtime.tar.gz") +DIGEST=${DIGEST%% *} +MARKER=/gnu/.stack-runtimes/$DIGEST +if [ -e "$MARKER" ] || [ -L "$MARKER" ]; then + [ -f "$MARKER" ] && [ ! -L "$MARKER" ] && [ "$(stat -c %u "$MARKER")" = 0 ] && + [ -z "$(find "$MARKER" -perm /222 -print)" ] && [ "$(cat "$MARKER")" = "$DIGEST" ] || + fail 'Invalid completed-runtime marker; refusing bootstrap.' + printf '%s\n' 'Guix runtime already installed.' + exit 0 +fi +STAGE=$(mktemp -d /gnu/.stack-stage.XXXXXX) +tar -xzf "$BUNDLE/runtime.tar.gz" --no-same-owner -C "$STAGE" +[ -d "$STAGE/gnu" ] && [ ! -L "$STAGE/gnu" ] && + [ -d "$STAGE/gnu/store" ] && [ ! -L "$STAGE/gnu/store" ] || fail 'Runtime has no canonical Guix store.' +set -- "$STAGE/gnu/store/"* +[ -e "$1" ] || [ -L "$1" ] || fail 'Runtime Guix store is empty.' +chown -hR 0:0 "$STAGE/gnu/store" +chmod -R a-w "$STAGE/gnu/store" +for ENTRY do + TARGET=/gnu/store/${ENTRY##*/} + if [ -e "$TARGET" ] || [ -L "$TARGET" ]; then + diff -qr --no-dereference -- "$ENTRY" "$TARGET" || fail 'Conflicting Guix store entry; existing data retained.' + [ -z "$(find "$TARGET" \( ! -user 0 -o \( ! -type l -perm /222 \) \) -print -quit)" ] || + fail 'Existing Guix store entry has unsafe ownership or write permissions.' + else + mv -T --no-clobber -- "$ENTRY" "$TARGET" + [ ! -e "$ENTRY" ] && [ ! -L "$ENTRY" ] || fail 'Guix store entry appeared during installation; retry bootstrap.' + fi +done +printf '%s\n' "$DIGEST" > "$STAGE/complete" +chmod 444 "$STAGE/complete" +mv -T --no-clobber -- "$STAGE/complete" "$MARKER" +[ ! -e "$STAGE/complete" ] || fail 'Runtime marker appeared during installation; retry bootstrap.' +printf '%s\n' 'Guix runtime installed. Run restore as the service user.' diff --git a/infra/guix/build.sh b/infra/guix/build.sh new file mode 100644 index 0000000..e5a0da8 --- /dev/null +++ b/infra/guix/build.sh @@ -0,0 +1,37 @@ +#!/bin/sh +# Copyright (C) 2026 Doan Luu Duc Tai +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +set -eu +umask 077 +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +[ "$(uname -s):$(uname -m)" = Linux:x86_64 ] || { echo 'Requires x86_64 Linux.' >&2; exit 1; } +command -v guix >/dev/null || { echo "Guix is required on the build machine." >&2; exit 1; } +mkdir -p "$ROOT/bin" +STAGE=$(mktemp -d "$ROOT/bin/.guix-build.XXXXXX") +trap 'chmod -R u+w -- "$STAGE"; rm -rf -- "$STAGE"' EXIT HUP INT TERM +guix describe --format=channels > "$STAGE/channels.scm" +PACK=$(guix time-machine -C "$STAGE/channels.scm" -- pack \ + --system=x86_64-linux -S /bin=bin -S /etc=etc \ + -m "$ROOT/infra/guix/manifest.scm") +cp -- "$PACK" "$STAGE/runtime.tar.gz" +mkdir "$STAGE/runtime" +tar -xzf "$STAGE/runtime.tar.gz" --no-same-owner -C "$STAGE/runtime" +PATH="$STAGE/runtime/bin:$PATH" CONTAINERS_CONF="$ROOT/infra/guix/containers.conf" \ + podman --root "$STAGE/storage" \ + --runroot "$STAGE/run" info >/dev/null +mv -- "$STAGE/runtime.tar.gz" "$ROOT/bin/guix-runtime.tar.gz" +mv -- "$STAGE/channels.scm" "$ROOT/bin/guix-channels.scm" +printf '%s\n' 'Guix runtime built. Full clean-host recovery test still required.' diff --git a/infra/guix/bundle.sh b/infra/guix/bundle.sh new file mode 100644 index 0000000..c6d5bb0 --- /dev/null +++ b/infra/guix/bundle.sh @@ -0,0 +1,56 @@ +#!/bin/sh +# Copyright (C) 2026 Doan Luu Duc Tai +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +set -eu +umask 077 +[ "$#" = 3 ] || { echo 'Usage: sh infra/guix/bundle.sh BACKUP.tar.gz.gpg RELEASE.tar.gz OUTPUT.tar.gz' >&2; exit 1; } +[ -f "$1" ] || { echo 'Encrypted backup missing.' >&2; exit 1; } +case "$1" in *.tar.gz.gpg) ;; *) echo 'Requires an encrypted stack backup.' >&2; exit 1 ;; esac +[ -f "$2" ] || { echo 'Trusted release missing.' >&2; exit 1; } +case "$2" in *.tar.gz) ;; *) echo 'Requires a trusted public release archive.' >&2; exit 1 ;; esac +case "$3" in *.tar.gz) ;; *) echo 'Output must end in .tar.gz.' >&2; exit 1 ;; esac +[ ! -e "$3" ] || { echo 'Output already exists.' >&2; exit 1; } +STAGE=$(mktemp -d "$(dirname -- "$3")/.recovery.XXXXXX") +trap 'rm -rf -- "$STAGE"' EXIT HUP INT TERM +tar -xOf "$2" stack/bin/stack > "$STAGE/verify" +chmod 700 "$STAGE/verify" +"$STAGE/verify" restore --release "$2" "$STAGE/release" +RELEASE="$STAGE/release" +[ ! -e "$RELEASE/.env" ] && [ ! -e "$RELEASE/var/data" ] || { + echo 'Release contains private application state; refusing plaintext publication.' >&2; exit 1; +} +for FILE in bin/stack bin/guix-runtime.tar.gz bin/guix-channels.scm \ + bin/chat/server bin/chat/crypto.wasm bin/chat/SOURCE.tar.gz infra/services.toml \ + infra/guix/bootstrap.sh infra/guix/restore.sh infra/guix/run.sh \ + infra/guix/containers.conf var/image-lock.json var/images.tar LICENSE; do + [ -f "$RELEASE/$FILE" ] || { echo "Release missing $FILE; build a complete release first." >&2; exit 1; } +done +mkdir "$STAGE/stack-recovery" +cp -- "$1" "$STAGE/stack-recovery/backup.tar.gz.gpg" +cp -- "$2" "$STAGE/stack-recovery/release.tar.gz" +cp -- "$RELEASE/bin/stack" "$STAGE/stack-recovery/stack" +cp -- "$RELEASE/bin/guix-runtime.tar.gz" "$STAGE/stack-recovery/runtime.tar.gz" +cp -- "$RELEASE/bin/guix-channels.scm" "$STAGE/stack-recovery/channels.scm" +cp -- "$RELEASE/infra/guix/restore.sh" "$STAGE/stack-recovery/restore" +cp -- "$RELEASE/infra/guix/bootstrap.sh" "$STAGE/stack-recovery/bootstrap" +cp -- "$RELEASE/infra/guix/run.sh" "$STAGE/stack-recovery/run" +cp -- "$RELEASE/infra/guix/containers.conf" "$STAGE/stack-recovery/containers.conf" +cp -- "$RELEASE/LICENSE" "$STAGE/stack-recovery/LICENSE" +chmod 700 "$STAGE/stack-recovery/restore" "$STAGE/stack-recovery/bootstrap" "$STAGE/stack-recovery/run" "$STAGE/stack-recovery/stack" +(cd "$STAGE/stack-recovery" && sha256sum backup.tar.gz.gpg release.tar.gz stack runtime.tar.gz channels.scm restore bootstrap run containers.conf LICENSE > SHA256SUMS) +tar -czf "$STAGE/bundle.tar.gz" -C "$STAGE" stack-recovery +ln -- "$STAGE/bundle.tar.gz" "$3" +printf 'Recovery bundle: %s\n' "$3" diff --git a/infra/guix/containers.conf b/infra/guix/containers.conf new file mode 100644 index 0000000..ab74f9e --- /dev/null +++ b/infra/guix/containers.conf @@ -0,0 +1,24 @@ +# Copyright (C) 2026 Doan Luu Duc Tai +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +[engine] +conmon_path = [] +runtime = "crun" + +[engine.runtimes] +crun = [] + +[network] +default_rootless_network_cmd = "slirp4netns" diff --git a/infra/guix/manifest.scm b/infra/guix/manifest.scm new file mode 100644 index 0000000..7e9cf19 --- /dev/null +++ b/infra/guix/manifest.scm @@ -0,0 +1,19 @@ +; Copyright (C) 2026 Doan Luu Duc Tai +; +; This program is free software: you can redistribute it and/or modify +; it under the terms of the GNU Affero General Public License as +; published by the Free Software Foundation, either version 3 of the +; License, or (at your option) any later version. +; +; This program is distributed in the hope that it will be useful, +; but WITHOUT ANY WARRANTY; without even the implied warranty of +; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +; GNU Affero General Public License for more details. +; +; You should have received a copy of the GNU Affero General Public License +; along with this program. If not, see . + +(specifications->manifest + '("bash" "coreutils" "tar" "gzip" "git" "gnupg" "nss-certs" + "podman" "crun" "conmon" "slirp4netns" "fuse-overlayfs" + "netavark" "aardvark-dns")) diff --git a/infra/guix/restore.sh b/infra/guix/restore.sh new file mode 100644 index 0000000..22e7e10 --- /dev/null +++ b/infra/guix/restore.sh @@ -0,0 +1,90 @@ +#!/bin/sh +# Copyright (C) 2026 Doan Luu Duc Tai +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +set -eu +umask 077 +[ "$#" = 1 ] || { echo 'Usage: ./restore /absolute/path/to/new-stack' >&2; exit 1; } +case "$1" in /*) ;; *) echo 'Destination must be absolute.' >&2; exit 1 ;; esac +[ "$(uname -s):$(uname -m)" = Linux:x86_64 ] || { echo 'Requires x86_64 Linux.' >&2; exit 1; } +[ "$(id -u)" != 0 ] || { echo 'Run as the service user, not root.' >&2; exit 1; } +BUNDLE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +(cd "$BUNDLE" && sha256sum --check --strict SHA256SUMS) +RELEASE_DIGEST=$(sha256sum "$BUNDLE/release.tar.gz") +RELEASE_DIGEST=${RELEASE_DIGEST%% *} +BACKUP_DIGEST=$(sha256sum "$BUNDLE/backup.tar.gz.gpg") +BACKUP_DIGEST=${BACKUP_DIGEST%% *} +INTENT="$RELEASE_DIGEST:$BACKUP_DIGEST" +MARKER="$1/var/guix-recovery" +pending() { + [ -f "$MARKER" ] && [ ! -L "$MARKER" ] && [ -O "$MARKER" ] && + [ "$(cat "$MARKER")" = "$INTENT" ] +} +RESUME=false +if [ -e "$1" ] || [ -L "$1" ]; then + pending || { echo 'Destination exists without a matching pending recovery; refusing to overwrite.' >&2; exit 1; } + RESUME=true +fi +DIGEST=$(sha256sum "$BUNDLE/runtime.tar.gz") +DIGEST=${DIGEST%% *} +[ -f "/gnu/.stack-runtimes/$DIGEST" ] && [ "$(stat -c %u "/gnu/.stack-runtimes/$DIGEST")" = 0 ] || { + echo 'Install the trusted closure first: sudo ./stack-recovery/bootstrap' >&2; exit 1; +} +for HELPER in newuidmap newgidmap; do + command -v "$HELPER" >/dev/null || { echo "Install host $HELPER before restoring." >&2; exit 1; } +done +for MAPPING in /etc/subuid /etc/subgid; do + awk -F: -v name="$(id -un)" -v uid="$(id -u)" \ + '($1 == name || $1 == uid) && $3 >= 65536 { found=1 } END { exit !found }' "$MAPPING" || { + echo "Allocate at least 65536 subordinate IDs in $MAPPING for $(id -un)." >&2; exit 1; + } +done +[ -n "${XDG_RUNTIME_DIR:-}" ] && [ -d "$XDG_RUNTIME_DIR" ] && [ -w "$XDG_RUNTIME_DIR" ] || { + echo 'A writable XDG_RUNTIME_DIR is required; use a normal login session.' >&2; exit 1; +} +[ "$(cat /proc/sys/net/ipv4/ip_unprivileged_port_start)" -le 80 ] || { + echo 'Host administrator must set net.ipv4.ip_unprivileged_port_start=80.' >&2; exit 1; +} +STAGE=$(mktemp -d) +trap 'chmod -R u+w -- "$STAGE"; rm -rf -- "$STAGE"' EXIT HUP INT TERM +mkdir "$STAGE/runtime" +tar -xzf "$BUNDLE/runtime.tar.gz" --no-same-owner -C "$STAGE/runtime" +[ -x "$STAGE/runtime/bin/podman" ] && [ -x "$STAGE/runtime/bin/gpg" ] || { + echo 'Guix closure incomplete; rerun the trusted capsule bootstrap.' >&2; exit 1; +} +PATH="$STAGE/runtime/bin:$PATH" +CONTAINERS_CONF="$BUNDLE/containers.conf" +export PATH CONTAINERS_CONF +podman --root "$STAGE/storage" --runroot "$STAGE/run" info >/dev/null +if [ "$RESUME" = false ]; then + "$BUNDLE/stack" restore "$BUNDLE/backup.tar.gz.gpg" "$1" +fi +LOCK="$1/var/guix-recovery.lock" +[ ! -L "$LOCK" ] && { [ ! -e "$LOCK" ] || [ -f "$LOCK" ]; } || { + echo 'Recovery lock is not a regular file.' >&2; exit 1; +} +exec 9>>"$LOCK" +flock -n 9 || { echo 'Another recovery is running for this destination.' >&2; exit 1; } +if [ "$RESUME" = true ]; then + pending || { echo 'Pending recovery changed; refusing activation.' >&2; exit 1; } +else + printf '%s\n' "$INTENT" > "$MARKER" +fi +[ -f "$1/var/image-lock.json" ] && [ -f "$1/var/images.tar" ] || { + echo 'Backup has no complete image export; recovered data retained. Use backup --include-images.' >&2; exit 1; +} +printf '%s\n' 'Data restored. Activating paired release; rerun this restore command after any failure.' +"$BUNDLE/stack" --root "$1" activate "$BUNDLE/release.tar.gz" 9>&- +rm -- "$MARKER" diff --git a/infra/guix/run.sh b/infra/guix/run.sh new file mode 100644 index 0000000..e1b9b99 --- /dev/null +++ b/infra/guix/run.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Copyright (C) 2026 Doan Luu Duc Tai +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +set -eu +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +if [ -f "$ROOT/bin/guix-runtime.tar.gz" ]; then + DIGEST=$(sha256sum "$ROOT/bin/guix-runtime.tar.gz") + DIGEST=${DIGEST%% *} + RUNTIME="$ROOT/var/guix/$DIGEST" + if [ ! -d "$RUNTIME" ]; then + umask 077 + mkdir -p "$ROOT/var/guix" + STAGE=$(mktemp -d "$ROOT/var/guix/.unpack.XXXXXX") + trap 'chmod -R u+w -- "$STAGE"; rm -rf -- "$STAGE"' EXIT HUP INT TERM + tar -xzf "$ROOT/bin/guix-runtime.tar.gz" --no-same-owner -C "$STAGE" + [ -x "$STAGE/bin/podman" ] && [ -x "$STAGE/bin/gpg" ] || { + echo 'Guix closure unavailable; run the trusted recovery capsule bootstrap as root first.' >&2; exit 1; + } + mv -T -- "$STAGE" "$RUNTIME" + trap - EXIT HUP INT TERM + fi + [ -x "$RUNTIME/bin/podman" ] && [ -x "$RUNTIME/bin/gpg" ] || { + echo 'Guix closure unavailable; run the trusted recovery capsule bootstrap as root first.' >&2; exit 1; + } + PATH="$RUNTIME/bin:$PATH" + CONTAINERS_CONF="$ROOT/infra/guix/containers.conf" + export PATH CONTAINERS_CONF + SSL_CERT_DIR="$RUNTIME/etc/ssl/certs" + SSL_CERT_FILE="$SSL_CERT_DIR/ca-certificates.crt" + export SSL_CERT_DIR SSL_CERT_FILE +fi +exec "$ROOT/bin/stack" --root "$ROOT" "$@" diff --git a/infra/guix/test-bootstrap.sh b/infra/guix/test-bootstrap.sh new file mode 100644 index 0000000..470ebeb --- /dev/null +++ b/infra/guix/test-bootstrap.sh @@ -0,0 +1,118 @@ +#!/bin/sh +# Copyright (C) 2026 Doan Luu Duc Tai +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +set -eu +umask 077 +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +command -v bwrap >/dev/null || { echo 'Bootstrap tests require bubblewrap.' >&2; exit 1; } +STAGE=$(mktemp -d) +trap 'chmod -R u+w "$STAGE"; rm -rf -- "$STAGE"' EXIT HUP INT TERM +mkdir -p "$STAGE/capsule" "$STAGE/pack/gnu/store/00000000000000000000000000000000-tool/bin" \ + "$STAGE/pack/gnu/store/11111111111111111111111111111111-profile/bin" "$STAGE/pack/etc" +cp "$ROOT/infra/guix/bootstrap.sh" "$STAGE/capsule/bootstrap" +printf '%s\n' '#!/bin/sh' 'printf "%s\n" canonical-tool' > "$STAGE/pack/gnu/store/00000000000000000000000000000000-tool/bin/tool" +chmod 755 "$STAGE/pack/gnu/store/00000000000000000000000000000000-tool/bin/tool" +ln -s /gnu/store/00000000000000000000000000000000-tool/bin/tool "$STAGE/pack/gnu/store/11111111111111111111111111111111-profile/bin/tool" +ln -s /gnu/store/11111111111111111111111111111111-profile/bin "$STAGE/pack/bin" +printf '%s\n' 'Must stay inside pack.' > "$STAGE/pack/etc/pack-sentinel" +tar -czf "$STAGE/capsule/runtime.tar.gz" -C "$STAGE/pack" . +(cd "$STAGE/capsule" && sha256sum bootstrap runtime.tar.gz > SHA256SUMS) +cp -a "$STAGE/capsule" "$STAGE/tampered" +printf '%s\n' 'tamper' >> "$STAGE/tampered/runtime.tar.gz" +cp -a "$STAGE/capsule" "$STAGE/next" +mkdir "$STAGE/pack/gnu/store/22222222222222222222222222222222-next" +printf '%s\n' 'Second closure.' > "$STAGE/pack/gnu/store/22222222222222222222222222222222-next/value" +tar -czf "$STAGE/next/runtime.tar.gz" -C "$STAGE/pack" . +(cd "$STAGE/next" && sha256sum bootstrap runtime.tar.gz > SHA256SUMS) +bwrap --unshare-user --uid 0 --gid 0 --unshare-pid --cap-add CAP_DAC_OVERRIDE \ + --tmpfs / --ro-bind /usr /usr \ + --symlink usr/bin /bin --symlink usr/lib /lib --symlink usr/lib /lib64 \ + --ro-bind "$STAGE/capsule" /capsule --ro-bind "$STAGE/next" /next \ + --ro-bind "$STAGE/tampered" /tampered --proc /proc --dev /dev --tmpfs /tmp \ + --chdir / /bin/sh -eu <<'TEST' +fail() { printf '%s\n' "$1" >&2; exit 1; } +reject() { if sh "$1/bootstrap"; then fail "$2"; fi; } +TOOL=/gnu/store/00000000000000000000000000000000-tool/bin/tool +PROFILE=/gnu/store/11111111111111111111111111111111-profile +mkdir /etc +printf '%s\n' 'Host configuration.' > /etc/host-sentinel +[ ! -e /gnu ] && [ ! -e /var/guix ] +if sh /capsule/bootstrap extra; then fail 'Accepted bootstrap arguments.'; fi +reject /tampered 'Accepted altered runtime.' +[ ! -e /gnu ] +sh /capsule/bootstrap > /tmp/bootstrap-first.log & +FIRST=$! +sh /capsule/bootstrap +wait "$FIRST" +DIGEST=$(sha256sum /capsule/runtime.tar.gz) +DIGEST=${DIGEST%% *} +[ "$(cat "/gnu/.stack-runtimes/$DIGEST")" = "$DIGEST" ] +[ -z "$(find /gnu/store -mindepth 1 ! -user 0 -print -quit)" ] +[ -z "$(find /gnu/store -mindepth 1 ! -type l -perm /222 -print -quit)" ] +[ "$("$PROFILE/bin/tool")" = canonical-tool ] +[ "$(readlink "$PROFILE/bin/tool")" = "$TOOL" ] +[ "$(readlink /bin)" = usr/bin ] +[ "$(cat /etc/host-sentinel)" = 'Host configuration.' ] +[ ! -e /etc/pack-sentinel ] +INODE=$(stat -c %i "/gnu/.stack-runtimes/$DIGEST") +sh /capsule/bootstrap +[ "$(stat -c %i "/gnu/.stack-runtimes/$DIGEST")" = "$INODE" ] +rm "/gnu/.stack-runtimes/$DIGEST" +chmod -R u+w "$PROFILE" +rm -rf "$PROFILE" +sh /capsule/bootstrap +[ "$("$PROFILE/bin/tool")" = canonical-tool ] +[ -f "/gnu/.stack-runtimes/$DIGEST" ] +[ -z "$(find /gnu -maxdepth 1 -name '.stack-stage.*' -print -quit)" ] +sh /next/bootstrap +[ "$("$PROFILE/bin/tool")" = canonical-tool ] +[ "$(cat /gnu/store/22222222222222222222222222222222-next/value)" = 'Second closure.' ] +[ -f "/gnu/.stack-runtimes/$DIGEST" ] +rm "/gnu/.stack-runtimes/$DIGEST" +chmod u+w "$TOOL" +printf '%s\n' 'conflicting content' >> "$TOOL" +chmod a-w "$TOOL" +reject /capsule 'Overwrote conflicting store entry.' +[ ! -e "/gnu/.stack-runtimes/$DIGEST" ] +[ -z "$(find /gnu -maxdepth 1 -name '.stack-stage.*' -print -quit)" ] +tail -n 1 "$TOOL" | grep -Fx 'conflicting content' +chmod -R u+w /gnu +rm -rf /gnu +mkdir -p /var/guix +reject /capsule 'Accepted managed Guix installation.' +[ ! -e /gnu ] +rm -rf /var/guix +mkdir -p /gnu/store +printf '%s\n' 'Foreign store.' > /gnu/store/sentinel +reject /capsule 'Accepted foreign store.' +[ "$(cat /gnu/store/sentinel)" = 'Foreign store.' ] +rm -rf /gnu +mkdir /outside +ln -s /outside /gnu +reject /capsule 'Accepted symlink /gnu.' +[ -z "$(find /outside -mindepth 1 -print -quit)" ] +rm /gnu +mkdir -p /gnu/.stack-runtimes +chmod 755 /gnu /gnu/.stack-runtimes +ln -s /outside /gnu/store +reject /capsule 'Accepted symlink store.' +[ -z "$(find /outside -mindepth 1 -print -quit)" ] +rm /gnu/store +chmod 777 /gnu/.stack-runtimes +reject /capsule 'Accepted writable ownership metadata.' +[ ! -e /gnu/store ] +printf '%s\n' 'Canonical bootstrap, retry, idempotency, preservation and refusal checks passed.' +TEST diff --git a/infra/guix/test-recovery.sh b/infra/guix/test-recovery.sh new file mode 100644 index 0000000..c03ed2f --- /dev/null +++ b/infra/guix/test-recovery.sh @@ -0,0 +1,133 @@ +#!/bin/sh +# Copyright (C) 2026 Doan Luu Duc Tai +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +set -eu +umask 077 +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +command -v bwrap >/dev/null || { echo 'Recovery tests require bubblewrap.' >&2; exit 1; } +STAGE=$(mktemp -d) +trap 'chmod -R u+w "$STAGE"; rm -rf -- "$STAGE"' EXIT HUP INT TERM +mkdir -p "$STAGE/capsule" "$STAGE/runtime/bin" "$STAGE/test-bin" +cp "$ROOT/infra/guix/restore.sh" "$STAGE/capsule/restore" +cp "$ROOT/infra/guix/run.sh" "$STAGE/capsule/run" +cp "$ROOT/infra/guix/containers.conf" "$STAGE/capsule/containers.conf" +printf '%s\n' 'opaque encrypted backup fixture' > "$STAGE/capsule/backup.tar.gz.gpg" +printf '%s\n' 'opaque release fixture' > "$STAGE/capsule/release.tar.gz" +printf '%s\n' 80 > "$STAGE/lowport" +printf '%s\n' '#!/bin/sh' 'exit 0' > "$STAGE/runtime/bin/gpg" +cat > "$STAGE/runtime/bin/podman" <<'PODMAN' +#!/bin/sh +set -eu +[ "$CONTAINERS_CONF" = /capsule/containers.conf ] +printf '%s\n' "$*" >> /tmp/podman.calls +PODMAN +cat > "$STAGE/test-bin/id" <<'ID' +#!/bin/sh +case "$1" in -u) echo 1000 ;; -un) echo service ;; *) exit 1 ;; esac +ID +for HELPER in newuidmap newgidmap; do + printf '%s\n' '#!/bin/sh' 'exit 0' > "$STAGE/test-bin/$HELPER" +done +cat > "$STAGE/capsule/stack" <<'STACK' +#!/bin/sh +set -eu +[ "$CONTAINERS_CONF" = /capsule/containers.conf ] +case "$(command -v podman)" in /tmp/*/runtime/bin/podman) ;; *) exit 97 ;; esac +case "$1" in + restore) + [ "$#" = 3 ] && [ "$2" = /capsule/backup.tar.gz.gpg ] + mkdir -p "$3/var/data" "$3/bin" + printf '%s\n' private-environment > "$3/.env" + printf '%s\n' encrypted-history > "$3/var/data/history" + printf '%s\n' images > "$3/var/images.tar" + printf '%s\n' image-lock > "$3/var/image-lock.json" + printf '%s\n' '#!/bin/sh' 'exit 99' > "$3/bin/stack" + chmod 700 "$3/bin/stack" + printf '%s\n' old-source > "$3/source" + echo restored >> /tmp/restorations + ;; + --root) + [ "$#" = 4 ] && [ "$3" = activate ] && [ "$4" = /capsule/release.tar.gz ] + [ ! -e /proc/self/fd/9 ] + [ "$(cat "$4")" = 'opaque release fixture' ] + echo activated >> /tmp/activations + [ ! -f /tmp/fail-activation ] || exit 42 + mkdir -p "$2/infra/guix" + printf '%s\n' '#!/bin/sh' 'echo new-management' > "$2/bin/stack" + chmod 700 "$2/bin/stack" + printf '%s\n' new-source > "$2/source" + cp /capsule/run "$2/infra/guix/run.sh" + cp /capsule/run "$2/bin/run" + cp /capsule/containers.conf "$2/infra/guix/containers.conf" + ;; + *) exit 98 ;; +esac +STACK +chmod 700 "$STAGE/capsule/stack" "$STAGE/runtime/bin/"* "$STAGE/test-bin/"* +tar -czf "$STAGE/capsule/runtime.tar.gz" -C "$STAGE/runtime" . +(cd "$STAGE/capsule" && sha256sum backup.tar.gz.gpg release.tar.gz runtime.tar.gz restore run stack containers.conf > SHA256SUMS) +bwrap --unshare-user --uid 0 --gid 0 --unshare-pid --cap-add CAP_DAC_OVERRIDE \ + --tmpfs / --ro-bind /usr /usr \ + --symlink usr/bin /bin --symlink usr/lib /lib --symlink usr/lib /lib64 \ + --ro-bind "$STAGE/capsule" /fixture --ro-bind "$STAGE/test-bin" /test-bin \ + --proc /proc --dev /dev --tmpfs /tmp \ + --ro-bind "$STAGE/lowport" /proc/sys/net/ipv4/ip_unprivileged_port_start \ + --setenv PATH /test-bin:/usr/bin --setenv XDG_RUNTIME_DIR /tmp/xdg \ + --chdir / /bin/sh -eu <<'TEST' +fail() { printf '%s\n' "$1" >&2; exit 1; } +reject() { if sh /capsule/restore "$1"; then fail "$2"; fi; } +cp -a /fixture /capsule +mkdir -p /etc /gnu/.stack-runtimes /tmp/xdg +printf '%s\n' service:100000:65536 > /etc/subuid +cp /etc/subuid /etc/subgid +DIGEST=$(sha256sum /capsule/runtime.tar.gz) +DIGEST=${DIGEST%% *} +printf '%s\n' "$DIGEST" > "/gnu/.stack-runtimes/$DIGEST" +touch /tmp/fail-activation +STATUS=0 +sh /capsule/restore /recovered || STATUS=$? +[ "$STATUS" = 42 ] || fail 'Did not reach the paired release activation.' +[ -f /recovered/var/guix-recovery ] +[ "$(cat /recovered/.env)" = private-environment ] +[ "$(cat /recovered/var/data/history)" = encrypted-history ] +[ "$(cat /recovered/source)" = old-source ] +cp /capsule/backup.tar.gz.gpg /tmp/original-backup +echo changed >> /capsule/backup.tar.gz.gpg +(cd /capsule && sha256sum backup.tar.gz.gpg release.tar.gz runtime.tar.gz restore run stack containers.conf > SHA256SUMS) +reject /recovered 'Accepted a different backup for pending recovery.' +[ "$(wc -l < /tmp/restorations)" = 1 ] && [ "$(wc -l < /tmp/activations)" = 1 ] +cp /tmp/original-backup /capsule/backup.tar.gz.gpg +(cd /capsule && sha256sum backup.tar.gz.gpg release.tar.gz runtime.tar.gz restore run stack containers.conf > SHA256SUMS) +exec 9>/recovered/var/guix-recovery.lock +flock 9 +reject /recovered 'Accepted concurrent recovery.' 9>&- +exec 9>&- +[ "$(wc -l < /tmp/activations)" = 1 ] +rm /tmp/fail-activation +sh /capsule/restore /recovered +[ "$(wc -l < /tmp/restorations)" = 1 ] && [ "$(wc -l < /tmp/activations)" = 2 ] +[ ! -e /recovered/var/guix-recovery ] +[ "$(/recovered/bin/stack)" = new-management ] && [ "$(cat /recovered/source)" = new-source ] +cmp /capsule/run /recovered/infra/guix/run.sh +cmp /capsule/containers.conf /recovered/infra/guix/containers.conf +[ "$(cat /recovered/.env)" = private-environment ] +[ "$(cat /recovered/var/data/history)" = encrypted-history ] +mkdir /unrelated +reject /unrelated 'Accepted unrelated existing destination.' +reject /recovered 'Repeated a completed recovery.' +[ "$(wc -l < /tmp/restorations)" = 1 ] && [ "$(wc -l < /tmp/activations)" = 2 ] +printf '%s\n' 'Synthetic recovery orchestration, resume, intent, locks and preservation passed; no real encryption or container lifecycle proved.' +TEST diff --git a/infra/guix/test.sh b/infra/guix/test.sh new file mode 100644 index 0000000..4425303 --- /dev/null +++ b/infra/guix/test.sh @@ -0,0 +1,166 @@ +#!/bin/sh +# Copyright (C) 2026 Doan Luu Duc Tai +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +set -eu +umask 077 +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +for SCRIPT in "$ROOT/infra/guix/"*.sh; do + sh -n "$SCRIPT" +done +sh "$ROOT/infra/guix/test-bootstrap.sh" +sh "$ROOT/infra/guix/test-recovery.sh" +STAGE=$(mktemp -d) +trap 'find "$STAGE" -type d -exec chmod u+w {} +; rm -rf -- "$STAGE"' EXIT HUP INT TERM +mkdir -p "$STAGE/source/infra/guix" "$STAGE/source/bin" "$STAGE/fake/bin" "$STAGE/toolbin" \ + "$STAGE/fake/gnu/store/readonly" "$STAGE/sentinel" +cp "$ROOT/infra/guix/"*.sh "$STAGE/source/infra/guix/" +cp "$ROOT/infra/podman.conf" "$STAGE/source/infra/podman.conf" +cp "$ROOT/infra/guix/containers.conf" "$STAGE/source/infra/guix/containers.conf" +printf '%s\n' 'ciphertext fixture' > "$STAGE/backup.tar.gz.gpg" +printf '%s\n' 'channels fixture' > "$STAGE/source/bin/guix-channels.scm" +printf '%s\n' '#!/bin/sh' 'printf "%s\n" "${CONTAINERS_CONF:-}" > "$0.environment"' 'printf "%s\n" "$@"' > "$STAGE/source/bin/stack" +chmod 700 "$STAGE/source/bin/stack" +for TOOL in podman gpg; do + printf '%s\n' '#!/bin/sh' 'exit 0' > "$STAGE/fake/bin/$TOOL" + chmod 700 "$STAGE/fake/bin/$TOOL" +done +printf '%s\n' 'Immutable runtime content.' > "$STAGE/fake/gnu/store/readonly/value" +printf '%s\n' 'Independent sentinel.' > "$STAGE/sentinel/value" +ln -s "$STAGE/sentinel" "$STAGE/fake/gnu/store/readonly/outside" +chmod 555 "$STAGE/sentinel" +chmod 444 "$STAGE/sentinel/value" +chmod 555 "$STAGE/fake/gnu/store" "$STAGE/fake/gnu/store/readonly" +chmod 444 "$STAGE/fake/gnu/store/readonly/value" +tar -czf "$STAGE/runtime.tar.gz" -C "$STAGE/fake" . +chmod -R u+w "$STAGE/fake/gnu/store" +cat > "$STAGE/toolbin/guix" <<'GUIX' +#!/bin/sh +set -eu +case "$1" in + describe) printf '%s\n' 'channels fixture' ;; + time-machine) printf '%s\n' "$FAKE_PACK" ;; + *) exit 1 ;; +esac +GUIX +chmod 700 "$STAGE/toolbin/guix" +FAKE_PACK="$STAGE/runtime.tar.gz" PATH="$STAGE/toolbin:$PATH" sh "$STAGE/source/infra/guix/build.sh" +[ -z "$(find "$STAGE/source/bin" -maxdepth 1 -name '.guix-build.*' -print -quit)" ] || { + echo 'Build left immutable staging directories.' >&2; exit 1; +} +[ "$(stat -c %a "$STAGE/sentinel")" = 555 ] +[ "$(stat -c %a "$STAGE/sentinel/value")" = 444 ] +cmp "$STAGE/runtime.tar.gz" "$STAGE/source/bin/guix-runtime.tar.gz" +mkdir -p "$STAGE/release/stack/bin/chat" "$STAGE/release/stack/infra/guix" "$STAGE/release/stack/var" +cp "$STAGE/source/bin/guix-runtime.tar.gz" "$STAGE/source/bin/guix-channels.scm" "$STAGE/release/stack/bin/" +cp "$STAGE/source/infra/guix/"*.sh "$STAGE/release/stack/infra/guix/" +cp "$ROOT/infra/guix/containers.conf" "$STAGE/release/stack/infra/guix/containers.conf" +cp "$ROOT/LICENSE" "$STAGE/release/stack/LICENSE" +printf '%s\n' 'services fixture' > "$STAGE/release/stack/infra/services.toml" +printf '%s\n' 'image lock fixture' > "$STAGE/release/stack/var/image-lock.json" +printf '%s\n' 'image export fixture' > "$STAGE/release/stack/var/images.tar" +for ARTIFACT in server crypto.wasm SOURCE.tar.gz; do + printf '%s\n' 'chat artifact fixture' > "$STAGE/release/stack/bin/chat/$ARTIFACT" +done +cat > "$STAGE/release/stack/bin/stack" <<'STACK' +#!/bin/sh +set -eu +[ "$#" = 4 ] && [ "$1" = restore ] && [ "$2" = --release ] || exit 1 +mkdir "$4" +tar -xzf "$3" --strip-components=1 -C "$4" +STACK +chmod 700 "$STAGE/release/stack/bin/stack" +tar -czf "$STAGE/release.tar.gz" -C "$STAGE/release" stack +sh "$STAGE/source/infra/guix/bundle.sh" "$STAGE/backup.tar.gz.gpg" "$STAGE/release.tar.gz" "$STAGE/recovery.tar.gz" +if sh "$STAGE/source/infra/guix/bundle.sh" "$STAGE/backup.tar.gz.gpg" "$STAGE/release.tar.gz" "$STAGE/recovery.tar.gz"; then + echo 'Overwrote existing archive.' >&2; exit 1 +fi +tar -xzf "$STAGE/recovery.tar.gz" -C "$STAGE" +[ -x "$STAGE/stack-recovery/bootstrap" ] || { echo 'Store bootstrap missing from capsule.' >&2; exit 1; } +cmp "$ROOT/infra/guix/containers.conf" "$STAGE/stack-recovery/containers.conf" +(cd "$STAGE/stack-recovery" && sha256sum --check --strict SHA256SUMS) +cmp "$STAGE/backup.tar.gz.gpg" "$STAGE/stack-recovery/backup.tar.gz.gpg" +cmp "$STAGE/release.tar.gz" "$STAGE/stack-recovery/release.tar.gz" +cmp "$STAGE/release/stack/bin/stack" "$STAGE/stack-recovery/stack" +cmp "$STAGE/release/stack/LICENSE" "$STAGE/stack-recovery/LICENSE" +[ "$(stat -c %a "$STAGE/recovery.tar.gz")" = 600 ] +if cmp -s "$STAGE/source/bin/stack" "$STAGE/stack-recovery/stack"; then + echo 'Bundled source-tree executable instead of release executable.' >&2; exit 1 +fi +if "$STAGE/stack-recovery/restore" "$STAGE/source"; then + echo 'Accepted existing restore destination.' >&2; exit 1 +fi +cp "$STAGE/source/infra/guix/run.sh" "$STAGE/source/bin/run" +OUTPUT=$(sh "$STAGE/source/bin/run" status) +EXPECTED=$(printf '%s\n' --root "$STAGE/source" status) +[ "$OUTPUT" = "$EXPECTED" ] +[ "$(cat "$STAGE/source/bin/stack.environment")" = "$STAGE/source/infra/guix/containers.conf" ] +mv "$STAGE/source/bin/guix-runtime.tar.gz" "$STAGE/saved-runtime.tar.gz" +OUTPUT=$(env -u CONTAINERS_CONF sh "$STAGE/source/bin/run" status) +[ "$OUTPUT" = "$EXPECTED" ] +[ "$(cat "$STAGE/source/bin/stack.environment")" = '' ] +mv "$STAGE/saved-runtime.tar.gz" "$STAGE/source/bin/guix-runtime.tar.gz" +find "$STAGE/source/var/guix" -name podman | read -r MATCH +PUBLISHED=$(sha256sum "$STAGE/source/bin/guix-runtime.tar.gz") +PUBLISHED=${PUBLISHED%% *} +DIRECTORY_MODE=$(stat -c %a "$STAGE/source/var/guix/$PUBLISHED/gnu/store/readonly") +FILE_MODE=$(stat -c %a "$STAGE/source/var/guix/$PUBLISHED/gnu/store/readonly/value") +chmod -R a-w "$STAGE/fake/gnu/store" +tar -czf "$STAGE/failed-runtime.tar.gz" -C "$STAGE/fake" bin/podman gnu +chmod -R u+w "$STAGE/fake/gnu/store" +cp "$STAGE/failed-runtime.tar.gz" "$STAGE/source/bin/guix-runtime.tar.gz" +if sh "$STAGE/source/bin/run" status; then + echo 'Accepted runtime missing GnuPG.' >&2; exit 1; +fi +[ -z "$(find "$STAGE/source/var/guix" -maxdepth 1 -name '.unpack.*' -print -quit)" ] || { + echo 'Failed launcher left immutable staging directories.' >&2; exit 1; +} +[ -x "$STAGE/source/var/guix/$PUBLISHED/bin/gpg" ] +[ "$(stat -c %a "$STAGE/source/var/guix/$PUBLISHED/gnu/store/readonly")" = "$DIRECTORY_MODE" ] +[ "$(stat -c %a "$STAGE/source/var/guix/$PUBLISHED/gnu/store/readonly/value")" = "$FILE_MODE" ] +[ "$(stat -c %a "$STAGE/sentinel")" = 555 ] +[ "$(stat -c %a "$STAGE/sentinel/value")" = 444 ] +cmp "$STAGE/runtime.tar.gz" "$STAGE/stack-recovery/runtime.tar.gz" +cp "$STAGE/runtime.tar.gz" "$STAGE/source/bin/guix-runtime.tar.gz" +printf 'tamper' >> "$STAGE/stack-recovery/backup.tar.gz.gpg" +if (cd "$STAGE/stack-recovery" && sha256sum --check --strict SHA256SUMS); then + echo 'Accepted changed ciphertext.' >&2; exit 1 +fi +rm "$STAGE/release/stack/bin/guix-channels.scm" +tar -czf "$STAGE/missing.tar.gz" -C "$STAGE/release" stack +if sh "$STAGE/source/infra/guix/bundle.sh" "$STAGE/backup.tar.gz.gpg" "$STAGE/missing.tar.gz" "$STAGE/other.tar.gz"; then + echo 'Accepted missing provenance.' >&2; exit 1 +fi +cp "$STAGE/source/bin/guix-channels.scm" "$STAGE/release/stack/bin/guix-channels.scm" +for ARTIFACT in image-lock.json images.tar; do + mv "$STAGE/release/stack/var/$ARTIFACT" "$STAGE/$ARTIFACT" + tar -czf "$STAGE/missing-image.tar.gz" -C "$STAGE/release" stack + if sh "$STAGE/source/infra/guix/bundle.sh" "$STAGE/backup.tar.gz.gpg" "$STAGE/missing-image.tar.gz" "$STAGE/missing-image-output.tar.gz"; then + echo 'Accepted release without complete saved images.' >&2; exit 1 + fi + [ ! -e "$STAGE/missing-image-output.tar.gz" ] + mv "$STAGE/$ARTIFACT" "$STAGE/release/stack/var/$ARTIFACT" +done +for PRIVATE in .env var/data; do + mkdir -p "$(dirname "$STAGE/release/stack/$PRIVATE")" + printf '%s\n' 'private fixture' > "$STAGE/release/stack/$PRIVATE" + tar -czf "$STAGE/private.tar.gz" -C "$STAGE/release" stack + if sh "$STAGE/source/infra/guix/bundle.sh" "$STAGE/backup.tar.gz.gpg" "$STAGE/private.tar.gz" "$STAGE/private-output.tar.gz"; then + echo 'Accepted private release contents.' >&2; exit 1 + fi + [ ! -e "$STAGE/private-output.tar.gz" ] + rm "$STAGE/release/stack/$PRIVATE" +done +printf '%s\n' 'Bundle, immutable cleanup, integrity, overwrite, launcher, and missing-artifact checks passed.' diff --git a/infra/stack.service b/infra/stack.service index 702da97..24e807a 100644 --- a/infra/stack.service +++ b/infra/stack.service @@ -23,8 +23,8 @@ Type=oneshot RemainAfterExit=yes KillMode=process WorkingDirectory=%h/stack -ExecStart=%h/stack/bin/stack up --production -ExecStop=%h/stack/bin/stack down +ExecStart=%h/stack/bin/run up --production +ExecStop=%h/stack/bin/run down TimeoutStartSec=300 TimeoutStopSec=120 Restart=on-failure diff --git a/src/app.rs b/src/app.rs index d655774..02f91ee 100644 --- a/src/app.rs +++ b/src/app.rs @@ -61,9 +61,10 @@ fn execute(arguments: Cli) -> Result<()> { if let Action::Restore { archive, destination, + release, } = &arguments.command { - return archive::restore(&absolute(archive)?, &absolute(destination)?); + return archive::restore(&absolute(archive)?, &absolute(destination)?, *release); } let project = Project::discover(arguments.root.as_deref())?; if let Action::Chat { @@ -132,22 +133,13 @@ fn execute(arguments: Cli) -> Result<()> { include_images, recipient, key, - } => { - let output = destination.map_or_else( - || Ok(project.root.join("var/exports")), - |path| absolute(&path), - )?; - let configuration = stack_config::Config::load(&project.root)?; - let selected_recipient = - recipient.unwrap_or(configuration.value("BACKUP_RECIPIENT")?.to_owned()); - archive::backup( - &project, - &output, - include_images, - &selected_recipient, - key.as_deref(), - ) - } + } => backup( + &project, + destination, + include_images, + recipient, + key.as_deref(), + ), Action::Release { destination } => { let output = destination .map_or_else(|| Ok(project.root.join("dist")), |path| absolute(&path))?; @@ -161,6 +153,23 @@ fn execute(arguments: Cli) -> Result<()> { } } +fn backup( + project: &Project, + destination: Option, + include_images: bool, + recipient: Option, + key: Option<&Path>, +) -> Result<()> { + let output = destination.map_or_else( + || Ok(project.root.join("var/exports")), + |path| absolute(&path), + )?; + let configuration = stack_config::Config::load(&project.root)?; + let selected_recipient = + recipient.unwrap_or(configuration.value("BACKUP_RECIPIENT")?.to_owned()); + archive::backup(project, &output, include_images, &selected_recipient, key) +} + fn chat( project: &Project, init: bool, @@ -207,6 +216,7 @@ fn licenses(project: &Project) -> Result<()> { } fn check(project: &Project) -> Result<()> { + process::run(project.command("sh")?.arg("infra/guix/test.sh"))?; process::run(project.command("sh")?.arg("infra/chat/build-typescript.sh"))?; process::run( project diff --git a/src/app/archive.rs b/src/app/archive.rs index 2a78644..0965430 100644 --- a/src/app/archive.rs +++ b/src/app/archive.rs @@ -152,13 +152,13 @@ fn snapshot_files(project: &Project, records: &[Value], stage: &Path) -> Result< }) .context("Caddy is not present")?; let id = field(caddy, &["Id"])?.as_str().context("Caddy has no ID")?; - let target = stage.join("var/data/caddy"); - fs::create_dir_all(&target)?; for name in ["data", "config"] { + let target = stage.join("var/data/caddy").join(name); + files::private_dir(&target)?; process::capture( project .command("podman")? - .args(["cp", &format!("{id}:/{name}")]) + .args(["cp", &format!("{id}:/{name}/.")]) .arg(&target), )?; } @@ -174,6 +174,7 @@ fn snapshot_files(project: &Project, records: &[Value], stage: &Path) -> Result< .context("Build a release locally before exporting so the backup includes a portable binary")?; fs::copy(executable, stage.join("bin/stack"))?; crate::app::chat::copy_artifacts(project, stage)?; + files::copy_runtime(&project.root, stage)?; Ok(()) } @@ -331,7 +332,29 @@ fn backup_inner( process::line(format_args!("Encrypted export: {}", output.display())) } -pub(super) fn restore(archive: &Path, destination: &Path) -> Result<()> { +fn restore_caddy(stage: &Path) -> Result<()> { + let root = stage.join("var/data/caddy"); + let legacy = root.join("caddy"); + if !legacy.exists() { + return Ok(()); + } + let data = root.join("data/caddy"); + let config = root.join("config/caddy"); + ensure!( + !data.exists() && !config.exists(), + "Backup contains ambiguous legacy and current Caddy layouts" + ); + files::private_dir(&root.join("data"))?; + fs::rename(&legacy, &data)?; + let autosave = data.join("autosave.json"); + if autosave.exists() { + files::private_dir(&config)?; + fs::rename(autosave, config.join("autosave.json"))?; + } + Ok(()) +} + +pub(super) fn restore(archive: &Path, destination: &Path, require_release: bool) -> Result<()> { ensure!(!destination.exists(), "Restore destination must not exist"); let parent = destination .parent() @@ -356,10 +379,17 @@ pub(super) fn restore(archive: &Path, destination: &Path) -> Result<()> { let stage = temp.path().join("stack"); files::extract(source, &stage)?; let manifest = files::verify(&stage)?; + ensure!( + !require_release || manifest.kind == "release", + "A public release archive is required" + ); ensure!( manifest.kind == "backup" || manifest.kind == "release", "Unknown archive kind" ); + if manifest.kind == "backup" { + restore_caddy(&stage)?; + } let project = Project::at(&stage)?; let mut settings = project.settings()?; settings.mode = Mode::Local; @@ -390,8 +420,9 @@ pub(super) fn restore(archive: &Path, destination: &Path) -> Result<()> { #[cfg(test)] mod tests { use std::fs; + use std::os::unix::fs::PermissionsExt; - use anyhow::Result; + use anyhow::{Context, Result}; use crate::app::files; use crate::app::project::{Mode, Project}; @@ -415,9 +446,7 @@ mod tests { restored.query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))?; anyhow::ensure!((count) == (1), "Test invariant failed"); anyhow::ensure!( - (std::os::unix::fs::PermissionsExt::mode(&fs::metadata(snapshot)?.permissions()) - & 0o777) - == (0o600), + (fs::metadata(snapshot)?.permissions().mode() & 0o777) == (0o600), "Test invariant failed" ); transaction.rollback()?; @@ -438,7 +467,7 @@ mod tests { let archive = workspace.path().join("backup.tar.gz"); files::pack(&source, &archive)?; let destination = workspace.path().join("restored"); - super::restore(&archive, &destination)?; + super::restore(&archive, &destination, false)?; anyhow::ensure!( (Project::at(&destination)?.settings()?.mode) == (Mode::Local), "Test invariant failed" @@ -448,26 +477,236 @@ mod tests { "Test invariant failed" ); anyhow::ensure!( - super::restore(&archive, &destination).is_err(), + super::restore(&archive, &destination, false).is_err(), "Test invariant failed" ); Ok(()) } + #[test] + fn restore_legacy_caddy_uses_active_mounts_without_changing_the_backup() -> Result<()> { + for autosave in [false, true] { + let workspace = tempfile::tempdir()?; + let source = workspace.path().join("source"); + let legacy = source.join("var/data/caddy/caddy"); + files::private_dir(&legacy)?; + for (name, bytes, mode) in [ + ("acme/account.key", b"account fixture".as_slice(), 0o600), + ("certificates/site.key", b"key fixture".as_slice(), 0o600), + ( + "certificates/site.crt", + b"certificate fixture".as_slice(), + 0o644, + ), + ] { + files::write(&legacy.join(name), bytes, mode)?; + } + if autosave { + files::write(&legacy.join("autosave.json"), b"config fixture", 0o600)?; + } + files::manifest(&source, "backup")?; + let original = files::inventory(&source)?; + let archive = workspace.path().join("backup.tar.gz"); + files::pack(&source, &archive)?; + let digest = files::hash_file(&archive)?; + let destination = workspace.path().join("restored"); + super::restore(&archive, &destination, false)?; + for name in [ + "acme/account.key", + "certificates/site.key", + "certificates/site.crt", + ] { + let restored = destination.join("var/data/caddy/data/caddy").join(name); + anyhow::ensure!( + fs::read(&restored)? == fs::read(legacy.join(name))?, + "Caddy credential bytes changed" + ); + anyhow::ensure!( + fs::metadata(&restored)?.permissions().mode() & 0o777 + == fs::metadata(legacy.join(name))?.permissions().mode() & 0o777, + "Caddy credential permissions changed" + ); + } + anyhow::ensure!( + fs::metadata(destination.join("var/data/caddy/data/caddy"))? + .permissions() + .mode() + & 0o777 + == 0o700, + "Caddy directory permissions changed" + ); + let config = destination.join("var/data/caddy/config/caddy/autosave.json"); + anyhow::ensure!(config.exists() == autosave, "Autosave presence changed"); + if autosave { + anyhow::ensure!( + fs::read(&config)? == b"config fixture", + "Autosave bytes changed" + ); + anyhow::ensure!( + fs::metadata(config)?.permissions().mode() & 0o777 == 0o600, + "Autosave permissions changed" + ); + } + anyhow::ensure!( + !destination.join("var/data/caddy/caddy").exists() + && !destination + .join("var/data/caddy/data/caddy/autosave.json") + .exists(), + "Legacy Caddy files remain outside their active mounts" + ); + anyhow::ensure!( + files::inventory(&source)? == original, + "Backup source changed" + ); + anyhow::ensure!( + files::hash_file(&archive)? == digest, + "Backup archive changed" + ); + } + Ok(()) + } + + #[test] + fn restore_rejects_ambiguous_caddy_layouts_without_publishing() -> Result<()> { + for name in ["data", "config"] { + let workspace = tempfile::tempdir()?; + let source = workspace.path().join("source"); + files::write( + &source.join("var/data/caddy/caddy/site.key"), + b"legacy", + 0o600, + )?; + files::write( + &source + .join("var/data/caddy") + .join(name) + .join("caddy/current"), + b"current", + 0o600, + )?; + files::manifest(&source, "backup")?; + let original = files::inventory(&source)?; + let archive = workspace.path().join("backup.tar.gz"); + files::pack(&source, &archive)?; + let digest = files::hash_file(&archive)?; + let destination = workspace.path().join("restored"); + anyhow::ensure!( + super::restore(&archive, &destination, false).is_err(), + "Ambiguous Caddy layouts were accepted" + ); + anyhow::ensure!(!destination.exists(), "Ambiguous backup was published"); + anyhow::ensure!( + files::inventory(&source)? == original, + "Backup source changed" + ); + anyhow::ensure!( + files::hash_file(&archive)? == digest, + "Backup archive changed" + ); + } + Ok(()) + } + + #[test] + fn restore_preserves_current_caddy_layout() -> Result<()> { + let workspace = tempfile::tempdir()?; + let source = workspace.path().join("source"); + let data = source.join("var/data/caddy/data/caddy"); + let config = source.join("var/data/caddy/config/caddy"); + files::write(&data.join("certificates/site.key"), b"key fixture", 0o600)?; + files::write(&config.join("autosave.json"), b"config fixture", 0o600)?; + files::manifest(&source, "backup")?; + let archive = workspace.path().join("backup.tar.gz"); + files::pack(&source, &archive)?; + let destination = workspace.path().join("restored"); + super::restore(&archive, &destination, false)?; + anyhow::ensure!( + files::inventory(&source.join("var/data/caddy"))? + == files::inventory(&destination.join("var/data/caddy"))?, + "Current Caddy layout changed" + ); + anyhow::ensure!( + fs::metadata(destination.join("var/data/caddy/data/caddy/certificates/site.key"))? + .permissions() + .mode() + & 0o777 + == 0o600, + "Current Caddy key permissions changed" + ); + Ok(()) + } + + #[test] + fn release_requirement_rejects_backup_and_accepts_release() -> Result<()> { + let workspace = tempfile::tempdir()?; + let source = workspace.path().join("source"); + files::private_dir(&source)?; + fs::write(source.join(".env"), "private fixture")?; + files::write( + &source.join("var/data/caddy/caddy/site.key"), + b"legacy fixture", + 0o600, + )?; + files::write( + &source.join("var/data/caddy/data/caddy/site.key"), + b"current fixture", + 0o600, + )?; + files::manifest(&source, "backup")?; + let original = files::inventory(&source)?; + let backup = workspace.path().join("backup.tar.gz"); + files::pack(&source, &backup)?; + let destination = workspace.path().join("restored"); + let error = super::restore(&backup, &destination, true) + .err() + .context("Private backup accepted as public release")?; + anyhow::ensure!( + error.to_string().contains("public release archive"), + "Caddy normalization ran before the release guard" + ); + anyhow::ensure!(!destination.exists(), "Rejected backup was published"); + anyhow::ensure!( + files::inventory(&source)? == original, + "Rejected backup changed the original source" + ); + fs::remove_file(source.join(".env"))?; + fs::remove_dir_all(source.join("var/data"))?; + files::manifest(&source, "release")?; + let release = workspace.path().join("release.tar.gz"); + files::pack(&source, &release)?; + super::restore(&release, &destination, true)?; + anyhow::ensure!(destination.is_dir(), "Public release was not restored"); + anyhow::ensure!( + !destination.join(".env").exists(), + "Private backup contents entered public release" + ); + Ok(()) + } + #[test] fn corrupt_restore_never_publishes_a_destination() -> Result<()> { let workspace = tempfile::tempdir()?; let source = workspace.path().join("source"); files::private_dir(&source)?; fs::write(source.join(".env"), "original")?; + for name in ["caddy", "data/caddy"] { + files::write( + &source.join("var/data/caddy").join(name).join("site.key"), + b"key fixture", + 0o600, + )?; + } files::manifest(&source, "backup")?; fs::write(source.join(".env"), "tampered")?; let archive = workspace.path().join("backup.tar.gz"); files::pack(&source, &archive)?; let destination = workspace.path().join("restored"); + let error = super::restore(&archive, &destination, false) + .err() + .context("Corrupt archive was accepted")?; anyhow::ensure!( - super::restore(&archive, &destination).is_err(), - "Test invariant failed" + error.to_string().contains("manifest"), + "Manifest was not verified first" ); anyhow::ensure!(!destination.exists(), "Test invariant failed"); Ok(()) diff --git a/src/app/cli.rs b/src/app/cli.rs index 1830ec1..f886812 100644 --- a/src/app/cli.rs +++ b/src/app/cli.rs @@ -72,6 +72,7 @@ pub(super) enum Action { Restore { archive: PathBuf, destination: PathBuf, + release: bool, }, Check, @@ -135,6 +136,7 @@ fn archives() -> [Command; 4] { .args([ path("archive").required(true), path("destination").required(true), + flag("release").help("Require a public release archive"), ]), Command::new("release") .about("Check and build a portable production release locally") @@ -227,6 +229,7 @@ fn action(name: &str, args: &mut ArgMatches) -> Result { "restore" => Action::Restore { archive: required(args, "archive")?, destination: required(args, "destination")?, + release: args.get_flag("release"), }, "check" => Action::Check, "release" => Action::Release { @@ -271,6 +274,22 @@ mod tests { use anyhow::Result; use std::path::PathBuf; + #[test] + fn restore_accepts_release_requirement() -> Result<()> { + let parsed = Cli::try_parse_from([ + "stack", + "restore", + "--release", + "release.tar.gz", + "new-root", + ])?; + anyhow::ensure!( + matches!(parsed.command, Action::Restore { release: true, .. }), + "Release requirement missing" + ); + Ok(()) + } + #[test] fn checked_command_schema() { command().debug_assert(); @@ -321,7 +340,7 @@ mod tests { anyhow::ensure!(Cli::try_parse_from(args).is_err(), "Test invariant failed"); } anyhow::ensure!( - matches!(Cli::try_parse_from(["stack", "restore", "export.gpg", "new-root"])?.command, Action::Restore { archive, destination } if archive == std::path::Path::new("export.gpg") && destination == std::path::Path::new("new-root")), + matches!(Cli::try_parse_from(["stack", "restore", "export.gpg", "new-root"])?.command, Action::Restore { archive, destination, release: false } if archive == std::path::Path::new("export.gpg") && destination == std::path::Path::new("new-root")), "Test invariant failed" ); Ok(()) diff --git a/src/app/files.rs b/src/app/files.rs index 2c612a9..e96e5a4 100644 --- a/src/app/files.rs +++ b/src/app/files.rs @@ -161,6 +161,34 @@ pub(super) fn copy_source(root: &Path, destination: &Path) -> Result<()> { Ok(()) } +pub(super) fn copy_runtime(root: &Path, destination: &Path) -> Result<()> { + let archive = root.join("bin/guix-runtime.tar.gz"); + let channels = root.join("bin/guix-channels.scm"); + ensure!( + archive.is_file() == channels.is_file(), + "Guix runtime archive and channel provenance must both be present" + ); + if archive.is_file() { + let bin = destination.join("bin"); + fs::create_dir_all(&bin)?; + for name in ["guix-runtime.tar.gz", "guix-channels.scm"] { + let source = root.join("bin").join(name); + regular_tree(&source)?; + let pending = tempfile::NamedTempFile::new_in(&bin)?; + fs::copy(source, pending.path())?; + pending.as_file().sync_all()?; + pending + .persist(bin.join(name)) + .map_err(|error| error.error)?; + } + } + let launcher = root.join("infra/guix/run.sh"); + if launcher.is_file() { + write(&destination.join("bin/run"), &fs::read(launcher)?, 0o755)?; + } + Ok(()) +} + pub(super) fn inventory(root: &Path) -> Result> { let mut files = BTreeMap::new(); for item in WalkDir::new(root).follow_links(false) { @@ -278,6 +306,55 @@ mod tests { use super::{copy_source, extract, manifest, member_path, pack, verify}; + #[test] + fn runtime_artifacts_survive_archive_round_trip() -> Result<()> { + let source = tempfile::tempdir()?; + let stage = tempfile::tempdir()?; + fs::create_dir_all(source.path().join("bin"))?; + fs::create_dir_all(source.path().join("infra/guix"))?; + fs::write(source.path().join("infra/guix/run.sh"), "launcher")?; + super::copy_runtime(source.path(), stage.path())?; + anyhow::ensure!(stage.path().join("bin/run").is_file(), "Launcher missing"); + fs::write(source.path().join("bin/guix-runtime.tar.gz"), "opaque pack")?; + anyhow::ensure!( + super::copy_runtime(source.path(), stage.path()).is_err(), + "Missing provenance accepted" + ); + fs::write( + source.path().join("bin/guix-channels.scm"), + "pinned channels", + )?; + super::copy_runtime(source.path(), stage.path())?; + fs::set_permissions( + stage.path().join("bin/guix-runtime.tar.gz"), + std::os::unix::fs::PermissionsExt::from_mode(0o400), + )?; + fs::write( + source.path().join("bin/guix-runtime.tar.gz"), + "updated pack", + )?; + super::copy_runtime(source.path(), stage.path())?; + anyhow::ensure!( + fs::read_to_string(stage.path().join("bin/guix-runtime.tar.gz"))? == "updated pack", + "Read-only runtime was not replaced" + ); + manifest(stage.path(), "backup")?; + let output = source.path().join("backup.tar.gz"); + pack(stage.path(), &output)?; + let restored = source.path().join("restored"); + extract(&output, &restored)?; + verify(&restored)?; + anyhow::ensure!( + fs::read_to_string(restored.join("bin/guix-runtime.tar.gz"))? == "updated pack", + "Runtime changed during recovery" + ); + anyhow::ensure!( + fs::read_to_string(restored.join("bin/guix-channels.scm"))? == "pinned channels", + "Provenance changed during recovery" + ); + Ok(()) + } + #[test] fn rejects_paths_outside_archive_root() -> Result<()> { let destination = Path::new("/restore"); diff --git a/src/app/images.rs b/src/app/images.rs index fac7aa7..b1105c2 100644 --- a/src/app/images.rs +++ b/src/app/images.rs @@ -130,10 +130,22 @@ fn save_inner( }, ); } + let policy = tempfile::NamedTempFile::new()?; + serde_json::to_writer( + policy.as_file(), + &serde_json::json!({ + "default": [{"type": "reject"}], + "transports": { + "containers-storage": {"": [{"type": "insecureAcceptAnything"}]} + } + }), + )?; let mut command = project.command("podman")?; + command + .args(["save", "--signature-policy"]) + .arg(policy.path()); command .args([ - "save", "--multi-image-archive", "--format", "docker-archive", @@ -178,10 +190,23 @@ pub(super) fn prepare(project: &Project, settings: &mut Settings) -> Result<()> archive.is_file(), "Release image archive is missing; deploy a complete release" ); + let policy = tempfile::NamedTempFile::new()?; + serde_json::to_writer( + policy.as_file(), + &serde_json::json!({ + "default": [{"type": "reject"}], + "transports": { + "docker-archive": {"": [{"type": "insecureAcceptAnything"}]}, + "oci-archive": {"": [{"type": "insecureAcceptAnything"}]} + } + }), + )?; process::run( project .command("podman")? - .args(["image", "load", "--input"]) + .args(["image", "load", "--signature-policy"]) + .arg(policy.path()) + .arg("--input") .arg(archive), )?; } diff --git a/src/app/release.rs b/src/app/release.rs index 268a3c2..40d8d72 100644 --- a/src/app/release.rs +++ b/src/app/release.rs @@ -47,6 +47,7 @@ pub(super) fn create(project: &Project, destination: &Path) -> Result<()> { files::private_dir(&stage)?; files::copy_source(&project.root, &stage)?; crate::app::chat::copy_artifacts(project, &stage)?; + files::copy_runtime(&project.root, &stage)?; fs::create_dir_all(stage.join("bin"))?; fs::copy( project @@ -116,6 +117,7 @@ pub(super) fn activate(archive: &Path, project: &Project) -> Result<()> { 0o755, )?; crate::app::chat::copy_artifacts(&pending, &project.root)?; + files::copy_runtime(&stage, &project.root)?; for name in ["images.tar", "image-lock.json"] { files::copy_tree( &stage.join("var").join(name), -- cgit v1.2.3